# ArchUnitPython turns architecture rules into ordinary pytest tests

> ArchUnitPython is a Python architecture testing library that parses your imports, builds a dependency graph and lets you express rules as a fluent chain, so layered architecture, cycle detection and code metrics are enforced by the same test runner as everything else. Its sharpest edge is the import root, which you have to tell it explicitly.

**LukasNiessen/ArchUnitPython** — ArchUnitPython is an architecture testing library. Specify and ensure architecture rules in your Python app. Easy setup and pipeline integration.

- Repository: https://github.com/LukasNiessen/ArchUnitPython
- Website: https://lukasniessen.github.io/ArchUnitPython/
- Stars: 667 · Forks: 8
- Language: Python
- License: MIT
- Published: 2026-10-21 · Updated: 2026-10-21 · Language: en
- Canonical page: https://hysenlabs.com/projects/lukasniessen-archunitpython

## The problem: an architecture convention that keeps eroding

ArchUnitPython is an architecture testing library for Python projects, and the README states its purpose plainly: enforce architecture rules in Python projects, check for dependency directions, detect circular dependencies, and enforce coding standards, integrating with pytest and any other testing framework.

The problem it addresses is familiar in any codebase that has grown. There is a convention about which layer may import which, there is a rule that the presentation layer must not touch the database directly, and there is an intuition that a file of two thousand lines is a problem. None of it is enforced, so a review has to catch it, and reviews miss it, usually under deadline. A test suite, by contrast, runs on every commit.

What makes this library different from a linter is the direction of the check. A linter looks at one file and complains about its style. ArchUnitPython looks at the whole project as a graph of imports and asserts properties of the graph: that no cycles exist, that the presentation folder does not depend on the database folder, that no file exceeds a line count, that a cohesion metric stays below a threshold.

The project positions itself in the ArchUnit tradition and is explicit about the lineage and the boundary: it is inspired by the ArchUnit library but not affiliated with it, and the README adds that it is the number one ArchUnit-style architecture testing library for Python measured by GitHub stars. The repository is MIT licensed, requires Python 3.10 or newer, has zero runtime dependencies, and is classified as Development Status 3 - Alpha in the package metadata.

The last push was on 2026-10-06 and the recent releases are v1.8.1, v1.8.0 and v1.7.0, so the project is moving quickly and recently.

## Five minutes to a first architecture test

Installation is one command, and the library deliberately has no runtime dependencies, so nothing else is pulled into your environment:

```bash
pip install archunitpython
```

The tests go into your existing test suite. The first example checks for circular dependencies:

```python
from archunitpython import project_files, metrics, assert_passes

def test_no_circular_dependencies():
    rule = project_files("src/").should().have_no_cycles()
    assert_passes(rule)
```

The shape is worth reading. project_files takes the directory to analyse and returns a selection. The selection has a should() method, and after that you name the property you want, and the result is a rule object that assert_passes evaluates. There is no separate DSL, no base class to subclass and no fixture to register. A rule is a value you can build, name and pass around.

The second example is a layering rule, written twice, once for the presentation layer and once for the business layer:

The rule selects the presentation folder, negates the expectation, and states that the selection must not depend on files in the database folder, using the in_folder selector with a glob path for each.

The third kind of rule is about metrics rather than dependencies:

```python
def test_no_large_files():
    rule = metrics("src/").count().lines_of_code().should_be_below(1000)
    assert_passes(rule)

def test_high_cohesion():
    # LCOM metric (lack of cohesion of methods), low = high cohesion
    rule = metrics("src/").lcom().lcom96b().should_be_below(0.3)
    assert_passes(rule)
```

The metric style mirrors the dependency style, with the metrics entry point replacing project_files and a different family of methods. The LCOM example carries a comment reminding you that a low value means high cohesion, which is the sort of thing that is easy to get backwards when you set the threshold.

Nothing about this is pytest specific. The README says it works with pytest, unittest, or any Python testing framework, and recommends pytest and its assert_passes() helper for cleaner assertion messages.

## The import root is the setting that decides whether your rules mean anything

There is one configuration decision in this library that the README spends more words on than the rules themselves, and it is worth reading before you write your first test.

The optional project_path argument to project_files(...), metrics(...), project_layers(...), project_slices(...) and project_graph(...) selects the directory to analyse. You pass the directory containing your top-level Python packages so that absolute imports can be resolved within that directory. Relative paths are interpreted from the current working directory, and omitting the argument uses that working directory.

The README is emphatic that it does not automatically discover a src layout. Omitting the argument uses the current working directory, it does not discover a src/ layout, and the library does not infer a src import root from pyproject.toml or from your Python environment; you pass it explicitly for that layout.

The example makes the reasoning concrete. Given a repository with src/taskboard/ containing __init__.py, application/board_service.py and domain/task.py, an import such as from taskboard.domain.task import Task names taskboard/domain/task.py underneath src/. It does not include src in the import name, so the argument is "src".

The table that follows gives the whole mapping:

| Package location, relative to your working directory | Import name | Argument |
| --- | --- | --- |
| `src/taskboard/` | `taskboard` | `"src"` |
| `taskboard/` | `taskboard` | `"."` |
| `backend/taskboard/` | `taskboard` | `"backend"` |

And the failure mode is stated clearly. With the wrong root, imports between your own modules can be classified as external, which causes cycle and internal-dependency rules to miss violations and dependency-based metrics to report incorrect values. The root also determines which files are included in metric measurements.

One more subtlety: selectors such as in_path with a glob and in_folder narrow which files a rule checks, but they do not change the import root. So a project_files call with an in_path selector can select files under src/ while still resolving absolute imports from the repository root. You set the root first and add selectors afterwards.

## Dependency graph reports as CI artifacts

Beyond pass and fail, the library can emit the dependency graph itself, and the README gives a recipe for turning that into a CI artifact so the architecture is visible rather than merely enforced.

```python
from archunitpython import project_graph

def test_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")

    assert graph.snapshot().summary.node_count >= 0
```

Three things are happening there. The graph is collapsed to a folder depth of two before export, which is what makes a large project readable, since a per-file graph of a few hundred modules is not a diagram anyone looks at. It is exported twice, as HTML and as Mermaid, which covers a browser artifact and a version-controlled text format. And a snapshot is taken with a summary that has a node count, which is a threshold you can assert on.

The CI integration itself is deliberately trivial, because the tests are ordinary tests:

```yaml
# GitHub Actions
- name: Run Architecture Tests
  run: pytest tests/test_architecture.py -v
```

Because the rules are pytest functions in a normal test file, there is nothing to configure. The same applies to unittest, and the architecture tests end up running wherever the rest of your suite runs, with the same reporting, the same failure output and the same merge gating.

That is the whole design argument of the library in one file. Architectural rules stop being a document nobody reads and become assertions that fail the build.

## Where ArchUnitPython is the wrong tool

Three limits, and the first is about what static import analysis can see.

The first is that the analysis is static and import-based. A dependency created at runtime, through a plugin registry, a dependency injection container or a service loader pattern, is invisible to it, and so is a dependency expressed in configuration rather than in an import statement. If your architecture rule is about the shape of your plugin system, this library will not help you, because the interesting dependencies in that design are the ones that are not imports.

The second is that the granularity of the model is the folder or path pattern. The rules shown are of the form in_folder with a glob path, then should_not, then depend_on_files, then another in_folder, which is a coarse instrument. A rule about a specific class not importing a specific class, or about a layer boundary that only holds for some of the packages in a folder, needs a more precise expression than a path glob, and the more precise it becomes the more you are writing your own traversal on top of the library's.

The third is that metric thresholds are a blunt tool and the library is happy to let you set them. A rule that no file exceeds 1000 lines of code is easy to write and easy to satisfy by renaming, splitting arbitrarily, or excluding the file. The LCOM threshold of 0.3 is the same. These rules are useful as ratchets that stop a file growing, and they are dangerous as absolute standards, and the library does not distinguish the two cases for you.

The alternative approach is a dedicated linter with an architecture plugin, such as the import-linter style tools, or a custom CI check that walks the AST itself. The difference in approach is that a linter is configured by rule text in a file and is understood by people who have never written Python, while ArchUnitPython is a library you call, which means the rules live in your test suite, in the language your team already writes, and are unit testable in the ordinary way. For a team that is comfortable with that, the flexibility is the point; for a team that wants a declarative rules file, it is friction.

## Conclusion

Adopt ArchUnitPython if your Python codebase has a layering or dependency convention that keeps eroding and you want it enforced in CI by the test runner you already use, since the library has zero runtime dependencies and its rules are plain test functions. Do not adopt it if you need a tool that reasons about runtime behaviour, since it is a static analysis over imports and will not see a dependency created dynamically. Verify first that you pass the right import root, because a wrong root makes your own modules look external and silently weakens cycle and dependency rules, that the rules you want are expressible with the in_folder, in_path and depend_on_files selectors, and that the metric thresholds you set, such as lines of code below 1000 or an LCOM96b below 0.3, reflect your codebase rather than a default you copied.

## FAQ

### What is ArchUnitPython and what does it enforce?

It is a Python architecture testing library that enforces architecture rules in Python projects, checking dependency directions, detecting circular dependencies and enforcing coding standards. It integrates with pytest and any other testing framework, has zero runtime dependencies, and is MIT licensed with Python 3.10 or newer required.

### How do I write my first architecture test with ArchUnitPython?

Call project_files with the directory to analyse, chain into a property such as have_no_cycles() or should_not().depend_on_files(), and pass the resulting rule to assert_passes. For example project_files("src/").should().have_no_cycles() inside a normal pytest test function, with the same import of project_files, metrics and assert_passes.

### What is the import root and why does it matter in ArchUnitPython?

The optional project_path argument to project_files, metrics, project_layers, project_slices and project_graph selects the directory containing your top-level packages so absolute imports resolve, and it defaults to the current working directory without discovering a src layout. With the wrong root, imports between your own modules are classified as external, which makes cycle and internal-dependency rules miss violations and dependency metrics report incorrect values.

### Can ArchUnitPython check code metrics as well as dependencies?

Yes. The metrics() entry point offers code metric rules in the same fluent style, for example metrics("src/").count().lines_of_code().should_be_below(1000) and metrics("src/").lcom().lcom96b().should_be_below(0.3), where a low LCOM value means high cohesion.

### How do I integrate ArchUnitPython into a CI pipeline?

The architecture rules are ordinary test functions, so the pipeline step is a normal pytest invocation, for example a GitHub Actions step running pytest tests/test_architecture.py -v. The library can also export the dependency graph as HTML and Mermaid, with collapse_to_folder_depth(2) for readability, and take a snapshot whose summary includes a node count you can assert on.

## Sources

- [License: MIT](https://github.com/LukasNiessen/ArchUnitPython/blob/main/LICENSE)
- [LukasNiessen/ArchUnitPython on GitHub](https://github.com/LukasNiessen/ArchUnitPython)
- [Project website](https://lukasniessen.github.io/ArchUnitPython/)
- [README](https://github.com/LukasNiessen/ArchUnitPython/blob/main/README.md)
- [Releases](https://github.com/LukasNiessen/ArchUnitPython/releases)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/lukasniessen-archunitpython
