Enforce architecture rules in Python projects. Check for dependency directions, detect circular dependencies, enforce coding standards and much more. Integrates with pytest and any other testing framework. Very simple setup and pipeline integration. Zero runtime dependencies.
The #1 ArchUnit-style architecture testing library for Python, measured by GitHub stars.
Inspired by the amazing ArchUnit library but we are not affiliated with ArchUnit.
Setup • Use Cases • Features • Why ArchUnitPython? • Sponsor • Contributing
pip install archunitpythonSimply add tests to your existing test suites. The following is an example using pytest. First we ensure that we have no circular dependencies.
fromarchunitpythonimportproject_files, metrics, assert_passesdeftest_no_circular_dependencies():
rule=project_files("src/").in_folder("src/**").should().have_no_cycles()
assert_passes(rule)Next we ensure that our layered architecture is respected.
deftest_presentation_should_not_depend_on_database():
rule= (
project_files("src/")
.in_folder("**/presentation/**")
.should_not()
.depend_on_files()
.in_folder("**/database/**")
)
assert_passes(rule)
deftest_business_should_not_depend_on_database():
rule= (
project_files("src/")
.in_folder("**/business/**")
.should_not()
.depend_on_files()
.in_folder("**/database/**")
)
assert_passes(rule)
# More layers ...Lastly we ensure that some code metric rules are met.
deftest_no_large_files():
rule=metrics("src/").count().lines_of_code().should_be_below(1000)
assert_passes(rule)
deftest_high_cohesion():
# LCOM metric (lack of cohesion of methods), low = high cohesionrule=metrics("src/").lcom().lcom96b().should_be_below(0.3)
assert_passes(rule)These tests run automatically in your testing setup, for example in your CI pipeline, so that's basically it. This setup ensures that the architectural rules you have defined are always adhered to!
# GitHub Actions
- name: Run Architecture Testsrun: pytest tests/test_architecture.py -vYou can also export dependency graph reports as CI artifacts:
fromarchunitpythonimportproject_graphdeftest_generate_dependency_graph_reports():
graph=project_graph("src/").titled("Application Architecture")
graph.collapse_to_folder_depth(2).export_as_html(
"reports/dependency-graph.html"
)
graph.export_as_mermaid("reports/dependency-graph.mmd")
assertgraph.snapshot().summary.node_count>=0Installation:
pip install archunitpythonThat's it. Works with pytest, unittest, or any Python testing framework.
Use assert_passes() for clean assertion messages:
fromarchunitpythonimportproject_files, assert_passesdeftest_my_architecture():
rule=project_files("src/").should().have_no_cycles()
assert_passes(rule)Use .check() directly and assert on the violations list:
fromarchunitpythonimportproject_filesrule=project_files("src/").should().have_no_cycles()
violations=rule.check()
assertlen(violations) ==0Both assert_passes() and .check() accept configuration options:
fromarchunitpythonimportCheckOptionsoptions=CheckOptions(
allow_empty_tests=True, # Don't fail when no files matchclear_cache=True, # Clear the graph cacheignore_type_checking_imports=True, # Ignore imports inside if TYPE_CHECKING
)
violations=rule.check(options)Attach a rationale to a rule so failing assertions explain why the rule exists:
rule= (
project_files("src/")
.in_folder("**/controllers/**")
.should_not()
.depend_on_files()
.in_folder("**/database/**")
.because("controllers should stay thin and delegate persistence")
)
assert_passes(rule)When the rule fails, the rationale is included in the assertion message.
Here is an overview of common use cases.
Layered Architecture:
Enforce that higher layers don't depend on lower layers and vice versa.
Clean Architecture / Hexagonal:
Validate that domain logic doesn't depend on infrastructure.
Microservices / Modular:
Ensure services/modules don't have forbidden cross-dependencies.
Here is a repository with a fully functioning example that uses ArchUnitPython to ensure architectural rules:
- RAG Pipeline Test Showcase: A test showcase demonstrating ArchUnitPython's architecture testing capabilities on a RAG pipeline
This is an overview of what you can do with ArchUnitPython.
deftest_services_cycle_free():
rule=project_files("src/").in_folder("**/services/**").should().have_no_cycles()
assert_passes(rule)deftest_clean_architecture_layers():
rule= (
project_files("src/")
.in_folder("**/presentation/**")
.should_not()
.depend_on_files()
.in_folder("**/database/**")
)
assert_passes(rule)
deftest_business_not_depend_on_presentation():
rule= (
project_files("src/")
.in_folder("**/business/**")
.should_not()
.depend_on_files()
.in_folder("**/presentation/**")
)
assert_passes(rule)fromarchunitpythonimportproject_layersdeftest_clean_architecture_layers():
rule= (
project_layers("src/")
.layer("presentation").defined_by_folder("**/presentation/**")
.layer("business").defined_by_folder("**/business/**")
.layer("database").defined_by_folder("**/database/**")
.where_layer("presentation")
.may_only_depend_on_layers("business")
.where_layer("business")
.may_only_depend_on_layers()
.where_layer("database")
.may_only_depend_on_layers()
)
assert_passes(rule)deftest_domain_does_not_import_requests():
rule= (
project_files("src/")
.in_folder("**/domain/**")
.should_not()
.depend_on_external_modules()
.matching("requests")
)
assert_passes(rule)fromarchunitpythonimportCheckOptionsdeftest_type_only_dependencies_do_not_count_as_runtime_coupling():
rule= (
project_files("src/")
.in_folder("**/api/**")
.should_not()
.depend_on_files()
.in_folder("**/infrastructure/**")
)
assert_passes(rule, CheckOptions(ignore_type_checking_imports=True))ArchUnitPython detects string-based dynamic imports such as importlib.import_module("my_app.adapters.sql") and __import__("my_app.adapters.sql"). For known migration shims, you can suppress one import edge locally:
frommy_app.adapters.sqlimportRepository# archunit: ignoredeftest_naming_patterns():
rule= (
project_files("src/")
.in_folder("**/services/**")
.should()
.have_name("*_service.py")
)
assert_passes(rule)deftest_no_large_files():
rule=metrics("src/").count().lines_of_code().should_be_below(1000)
assert_passes(rule)
deftest_high_class_cohesion():
rule=metrics("src/").lcom().lcom96b().should_be_below(0.3)
assert_passes(rule)
deftest_method_count():
rule=metrics("src/").count().method_count().should_be_below(20)
assert_passes(rule)
deftest_field_count_for_data_classes():
rule= (
metrics("src/")
.for_classes_matching("*Data*")
.count()
.field_count()
.should_be(3)
)
assert_passes(rule)deftest_proper_coupling():
rule=metrics("src/").distance().distance_from_main_sequence().should_be_below(0.3)
assert_passes(rule)
deftest_not_in_zone_of_pain():
rule=metrics("src/").distance().not_in_zone_of_pain()
assert_passes(rule)You can define your own custom rules.
rule_desc="Python files should have docstrings"defhas_docstring(file):
return'"""'infile.contentor"'''"infile.contentviolations= (
project_files("src/")
.with_name("*.py")
.should()
.adhere_to(has_docstring, rule_desc)
.check()
)
assertlen(violations) ==0You can define your own metrics as well.
deftest_method_field_ratio():
rule= (
metrics("src/")
.custom_metric(
"methodFieldRatio",
"Ratio of methods to fields",
lambdaci: len(ci.methods) /max(len(ci.fields), 1),
)
.should_be_below(10)
)
assert_passes(rule)importrefromarchunitpythonimportproject_slicesdeftest_adhere_to_diagram():
diagram="""@startuml component [controllers] component [services] [controllers] --> [services]@enduml"""rule= (
project_slices("src/")
.defined_by_regex(re.compile(r"/([^/]+)/[^/]+\.py$"))
.should()
.adhere_to_diagram(diagram)
)
assert_passes(rule)
deftest_no_forbidden_dependency():
rule= (
project_slices("src/")
.defined_by("src/(**)/**")
.should_not()
.contain_dependency("services", "controllers")
)
assert_passes(rule)Generate dependency graph reports in multiple formats and narrow them to the part of the codebase you want to inspect.
Using requests library repo for example
fromarchunitpythonimportproject_graphdeftest_export_dependency_graph_reports():
graph=project_graph("src/requests").titled("Application Architecture")
graph.collapse_to_folder_depth(2).export_as_mermaid("reports/dependencies.md")
if__name__=="__main__":
test_export_dependency_graph_reports()Exported mermaid diagram
flowchart LR
n0["__init__.py"]
n1["__version__.py"]
n2["_internal_utils.py"]
n3["_types.py"]
n4["adapters.py"]
n5["api.py"]
n6["auth.py"]
n7["certs.py"]
n8["compat.py"]
n9["cookies.py"]
n10["exceptions.py"]
n11["help.py"]
n12["hooks.py"]
n13["models.py"]
n14["packages.py"]
n15["sessions.py"]
n16["status_codes.py"]
n17["structures.py"]
n18["utils.py"]
n0 --> n1
n0 --> n5
n0 --> n10
n0 --> n13
n0 --> n15
n0 --> n16
n2 --> n8
n3 --> n6
n3 --> n9
n3 --> n13
n3 --> n17
n4 --> n0
n4 --> n3
n4 --> n6
n4 --> n8
n4 --> n9
n4 --> n10
n4 --> n13
n4 --> n17
n4 --> n18
n5 --> n0
n5 --> n13
n6 --> n2
n6 --> n8
n6 --> n9
n6 --> n13
n6 --> n18
n9 --> n2
n9 --> n3
n9 --> n8
n9 --> n13
n10 --> n8
n10 --> n13
n11 --> n0
n12 --> n0
n12 --> n13
n13 --> n0
n13 --> n2
n13 --> n4
n13 --> n6
n13 --> n8
n13 --> n9
n13 --> n10
n13 --> n12
n13 --> n16
n13 --> n17
n13 --> n18
n14 --> n8
n15 --> n0
n15 --> n2
n15 --> n3
n15 --> n4
n15 --> n6
n15 --> n8
n15 --> n9
n15 --> n10
n15 --> n12
n15 --> n13
n15 --> n16
n15 --> n17
n15 --> n18
n16 --> n17
n17 --> n8
n18 --> n0
n18 --> n1
n18 --> n2
n18 --> n3
n18 --> n8
n18 --> n9
n18 --> n10
n18 --> n13
n18 --> n17
Supported formats:
- DOT (
export_as_dot,to_dot) - Mermaid (
export_as_mermaid,to_mermaid) - D2 (
export_as_d2,to_d2) - CSV (
export_as_csv,to_csv) - JSON (
export_as_json,to_json) - HTML (
export_as_html,to_html)
Graph exploration options:
focus_on(pattern, depth)keeps matching files and their neighbors.reachable_from(pattern)keeps matching files and their transitive dependencies.dependents_of(pattern)keeps files that transitively depend on the matching files.collapse_to_folder_depth(depth)aggregates files to folder-level graph nodes.collapse_by_pattern(pattern, replacement)maps files to custom graph nodes.include_external_dependencies()includes imports to external modules such asrequestsorsqlalchemy.include_self_dependencies()keeps self edges that are normally hidden in reports.
When you create reports through project_graph("src/"), internal file paths are displayed relative to that project root so the output stays readable.
Generate HTML reports for your metrics. Note that this feature is in beta.
fromarchunitpython.metrics.fluentapi.export_utilsimportMetricsExporter, ExportOptionsMetricsExporter.export_as_html(
{"MethodCount": 5, "FieldCount": 3, "LinesOfCode": 150},
ExportOptions(
output_path="reports/metrics.html",
title="Architecture Metrics Dashboard",
),
)We offer three targeting options for pattern matching across all modules:
with_name(pattern)- Pattern is checked against the filename (e.g.service.pyfromsrc/services/service.py)in_path(pattern)- Pattern is checked against the full relative path (e.g.src/services/service.py)in_folder(pattern)- Pattern is checked against the path without filename (e.g.src/servicesfromsrc/services/service.py)
For the metrics module there is an additional one:
for_classes_matching(pattern)- Pattern is checked against class names. The filepath or filename does not matter here
We support string patterns and regular expressions. String patterns support glob.
# String patterns with glob support (case sensitive)
.with_name("*_service.py") # All files ending with _service.py
.in_folder("**/services") # All files in any services folder
.in_path("src/api/**/*.py") # All Python files under src/api# Regular expressionsimportre
.with_name(re.compile(r".*Service\.py$"))
.in_folder(re.compile(r"services$"))
# For metrics module: Class name matching
.for_classes_matching("*Service*")
.for_classes_matching(re.compile(r"^User.*"))*- Matches any characters within a single path segment (except/)**- Matches any characters across multiple path segments?- Matches exactly one character
# Filename patterns
.with_name("*.py") # All Python files
.with_name("*_service.py") # Files ending with _service.py
.with_name("test_*.py") # Files starting with test_# Folder patterns
.in_folder("**/services") # Any services folder at any depth
.in_folder("src/services") # Exact src/services folder
.in_folder("**/test/**") # Any folder containing test in path# Path patterns
.in_path("src/**/*.py") # Python files anywhere under src
.in_path("**/test/**/*_test.py") # Test files in any test folderWe generally recommend using string patterns with glob support unless you need very special cases. Regular expressions add extra complexity that is not necessary for most cases.
The LCOM metrics measure how well the methods and fields of a class are connected. Lower values indicate better cohesion.
# LCOM96a (Henderson et al.)metrics("src/").lcom().lcom96a().should_be_below(0.8)
# LCOM96b (Henderson et al.) - most commonly usedmetrics("src/").lcom().lcom96b().should_be_below(0.7)All 8 LCOM variants are available: lcom96a(), lcom96b(), lcom1() through lcom5(), and lcomstar().
The LCOM96b metric is calculated as:
LCOM96b = (1/a) * sum((1/m) * (m - mu(Ai)))
Where:
mis the number of methods in the classais the number of attributes (fields) in the classmu(Ai)is the number of methods that access attribute Ai
The result is a value between 0 and 1:
- 0: perfect cohesion (all methods access all attributes)
- 1: complete lack of cohesion (each method accesses its own attribute)
metrics("src/").count().method_count().should_be_below(20)
metrics("src/").count().field_count().should_be_below(15)
metrics("src/").count().lines_of_code().should_be_below(200)
metrics("src/").count().statements().should_be_below(100)
metrics("src/").count().imports().should_be_below(20)metrics("src/").distance().abstractness().should_be_above(0.3)
metrics("src/").distance().instability().should_be_below(0.8)
metrics("src/").distance().distance_from_main_sequence().should_be_below(0.5)metrics("src/").custom_metric(
"complexityRatio",
"Ratio of methods to fields",
lambdaci: len(ci.methods) /max(len(ci.fields), 1),
).should_be_below(3.0)ArchUnitPython can validate your architecture against PlantUML diagrams, ensuring your code matches your architectural designs.
deftest_component_architecture():
diagram="""@startumlcomponent [UserInterface]component [BusinessLogic]component [DataAccess][UserInterface] --> [BusinessLogic][BusinessLogic] --> [DataAccess]@enduml"""rule= (
project_slices("src/")
.defined_by("src/(**)/**")
.should()
.adhere_to_diagram(diagram)
)
assert_passes(rule)deftest_from_file():
rule= (
project_slices("src/")
.defined_by("src/(**)/**")
.should()
.adhere_to_diagram_in_file("docs/architecture.puml")
)
assert_passes(rule)Here's how ArchUnitPython compares to other Python architecture-enforcement libraries.
ArchUnitPython is optimized for architecture rules as tests: rules live next to your normal unit tests, run in pytest/unittest/CI, and fail with test-style violation messages. Broader CLI-first tools such as Tach and Import Linter are excellent adjacent tools, but they solve the problem through separate configuration and commands rather than a test-native ArchUnit-style API.
| Feature | ArchUnitPython | Tach | Import Linter | PyTestArch |
|---|---|---|---|---|
| Primary workflow | ✅ Architecture rules as unit tests | tach.toml | ||
| ArchUnit-style fluent API | ✅ Yes | ❌ No | ❌ No | |
| Testing framework integration | ✅ pytest, unittest, any runner | |||
| Zero runtime dependencies | ✅ Standard library only | ❌ Tool dependencies | ❌ Tool dependencies | |
| Circular dependency detection | ✅ First-class | ✅ First-class | ||
| File/folder dependency rules | ✅ Glob + regex | ✅ Module config | ✅ Import contracts | ✅ Module rules |
| Named layer rules | ✅ project_layers() | ✅ Supported | ✅ Supported | ✅ Supported |
| External dependency rules | ✅ depend_on_external_modules() | |||
| TYPE_CHECKING-aware analysis | ✅ Configurable | |||
| Dynamic import detection | ✅ importlib + __import__ string calls | |||
| Inline ignore directives | ✅ # archunit: ignore | ✅ Supported | ||
| Naming convention checks | ✅ Files and paths | ❌ No | ❌ No | |
| Code metrics | ✅ Counts, LCOM, distance metrics | ❌ No | ❌ No | ❌ No |
| Custom rules and metrics | ✅ Full support | ❌ No | ||
| PlantUML diagram validation | ✅ Supported | ❌ No | ❌ No | ❌ No |
| Empty test protection | ✅ Fails by default | |||
| Graph/reporting | ✅ DOT, Mermaid, D2, CSV, JSON, HTML graph reports + metrics HTML | ✅ DOT, JSON, web graph | ✅ Browser UI | |
| Best fit | Architecture tests, CI fitness functions, metrics, diagrams | Modular monolith dependency governance | Config-driven import contracts | pytest import-boundary checks |
The most important differences:
- Test-native by design: ArchUnitPython rules are just Python tests, so architecture decisions are reviewed, run, and debugged in the same workflow as the rest of your test suite.
- Broader rule surface: dependency direction, cycles, layer policies, external modules, type-only imports, dynamic imports, naming, metrics, custom rules, and PlantUML validation live in one API.
- False-positive protection: empty checks fail by default, which helps catch typos in file and folder patterns before they silently make your architecture tests meaningless.
- Quality beyond imports: ArchUnitPython can enforce code metrics such as LCOM cohesion, field/method counts, abstractness, instability, and distance from the main sequence.
When tests fail, you get helpful output with file paths and violation details:
Found 2 architecture violation(s):
1. File dependency violation
'src/api/bad_shortcut.py' depends on 'src/retrieval/vector_store.py'
2. File dependency violation
'src/api/bad_shortcut.py' depends on 'src/retrieval/embedder.py'
We support logging to help you understand what files are being analyzed and troubleshoot test failures. Logging is disabled by default to keep test output clean.
fromarchunitpythonimportCheckOptionsfromarchunitpython.common.logging.typesimportLoggingOptionsoptions=CheckOptions(
logging=LoggingOptions(
enabled=True,
level="debug", # "error" | "warn" | "info" | "debug"log_file=True, # Creates logs/archunit-YYYY-MM-DD_HH-MM-SS.log
),
)
violations=rule.check(options)# GitHub Actions
- name: Run Architecture Testsrun: pytest tests/test_architecture.py -v
- name: Upload Test Logsif: always()uses: actions/upload-artifact@v3with:
name: architecture-test-logspath: logs/The features of ArchUnitPython can very well be used as architectural fitness functions. See here for more information about that topic.
| Module | Description | Status |
|---|---|---|
| Files | File and folder based rules | Stable |
| Metrics | Code quality metrics | Stable |
| Slices | Architecture slicing | Stable |
| Graph | Dependency graph reports | Experimental |
| Testing | Test framework integration | Stable |
| Common | Shared utilities | Stable |
| Reports | Generate HTML reports | Experimental |
We use ourselves to ensure the architectural rules for this repository.
We highly appreciate contributions. See Contributing for the full workflow.
- Use feature branches and open pull requests against
main. - Use Conventional Commits so releases can be versioned automatically.
- Do not bump versions manually for normal feature or fix work; semantic-release updates
pyproject.toml,src/archunitpython/__init__.py, andCHANGELOG.md. - CI checks linting, typing, tests, package builds, and release metadata sync.
Q: What Python testing frameworks are supported?
ArchUnitPython works with pytest, unittest, and any other testing framework. We recommend pytest with assert_passes().
Q: What Python versions are supported?
Python 3.10 and above.
Q: Does ArchUnitPython have any runtime dependencies?
No. ArchUnitPython uses only the Python standard library. Development dependencies (pytest, mypy, ruff) are optional.
Q: How does it analyze Python imports?
ArchUnitPython uses Python's built-in ast module to parse source files and resolve imports. It handles absolute imports, relative imports, and package imports.
Q: How do I handle false positives in architecture rules?
Use the filtering and targeting capabilities to exclude specific files or patterns. You can filter by file paths, class names, or custom predicates to fine-tune your rules.
ArchUnitPython is the Python port of ArchUnitTS. We plan to keep it in sync with the TypeScript version's features, and extend it with Python-specific capabilities.
ArchUnitPython started as the Python port of ArchUnitTS. With the rise of LLMs and AI integration, enforcing architectural boundaries and QA in general has become more critical than ever -- especially in Python, the dominant language in the AI/ML ecosystem.
- LukasNiessen - Creator and main maintainer
Found a bug? Want to discuss features?
- Submit an issue on GitHub
- Join our GitHub Discussions
If ArchUnitPython helps your project, please consider:
- Starring the repository 💚
- Sponsoring development via GitHub Sponsors
- Suggesting new features 💭
- Contributing code or documentation ⌨️
This project is under the MIT license.
