Enforcing Python Architecture Rules with PyTestArch

Enforcing Python Architecture Rules with PyTestArch

10 min read

You drew the nice layered diagram. You agreed in the kickoff meeting that the models package has no business importing anything beyond pydantic. You even wrote it in the README. Six months later, a new team member runs grep -r "from aws_lambda_powertools" src/models/ and finds three hits. Architecture documents are fiction the moment the tests stop checking them.

PyTestArch is a small library that lets you express architectural rules — “this package must not import that one”, “models only depend on pydantic and stdlib” — as regular pytest tests. Inspired by Java’s ArchUnit, it scans your imports, builds a dependency graph, and fails the build when someone crosses a line you drew.

Crossing guard holding up a stop sign — the architecture test, politely refusing your stray import

In this post, I’ll walk through a uv-workspace monorepo with two mock services, enforce three architectural rules, and wire them into CI. The full demo is in the pytestarch-demo repo.

Why Bother?

“We’ll catch it in code review” is the same energy as “we’ll write the tests later”. It works right up until the reviewer is tired, the PR is big, or the import is five levels deep in a file nobody opened. Architecture problems usually grow slowly and quietly. One wrong import doesn’t break the system right away — it just makes it easier for the next wrong import to happen.

Making rules into tests changes what you have to do to break them:

  • Review becomes easier, not harder. Instead of tracing imports in their head, the reviewer sees a green check. The boring stuff is automated; humans focus on what actually needs judgment.
  • Rules are discoverable. A new contributor doesn’t need to read a wiki. They run the test suite, see a failure, and read the rule in test_architecture.py.
  • Rules evolve with the code. Relaxing or tightening a rule is a diff in a test file. It’s reviewable, reversible, and — unlike a Confluence page — nobody forgets to update it.
  • Monorepos especially benefit. The more packages share a repo, the more tempting it is to from ..other_service.internal import thing. A test that says “customer-service must not import order-service” is the cheapest insurance policy you’ll ever write.

What We’re Building

The demo repo is a uv workspace with two microservices and one shared library — similar to the setup from my previous post on uv Lambda packaging:

pytestarch-demo/
├── shared-libs/
│ └── custom-core/ # utilities any service may use
├── customer-service/
│ ├── customer-models/ # pydantic models
│ └── get-customer/ # AWS Lambda handler
├── order-service/
│ ├── order-models/ # pydantic models
│ └── create-order/ # AWS Lambda handler
└── tests/
├── conftest.py
└── test_architecture.py

Three rules we want to enforce:

  1. *-models packages may only depend on pydantic and the Python standard library. Models are data, not behaviour — no logging frameworks, no AWS SDKs, no shared utils sneaking in.
  2. customer-service must not import order-service code.
  3. order-service must not import customer-service code.

Shared libraries like custom-core and external dependencies (pydantic, aws_lambda_powertools) are fair game from either service.

The allowed dependency flow looks like this — handlers depend on their own models, shared libs, and external packages; models stay lean on pydantic and stdlib:

flowchart TD
    gc["get-customer"] --> cm["customer-models"]
    co["create-order"] --> om["order-models"]
    gc --> cc["custom-core"]
    co --> cc
    gc --> pwt["aws_lambda_powertools"]
    co --> pwt
    cm --> pyd["pydantic"]
    om --> pyd
    cm --> std["stdlib"]
    om --> std

And here are the edges we want PyTestArch to forbid — cross-service imports, and models reaching for anything beyond pydantic/stdlib:

flowchart LR
    cs["customer-service"] -. "❌" .-> os["order-service"]
    os -. "❌" .-> cs
    cm2["customer-models / order-models"] -. "❌" .-> ext["custom-core, aws_lambda_powertools, …"]

    classDef forbidden stroke:#d9534f,stroke-width:2px
    class cs,os,cm2,ext forbidden

Installing PyTestArch

Add it as a dev dependency:

Terminal window
uv add --dev pytestarch pytest

PyTestArch ships an optional visualization extra (matplotlib) if you want to render the dependency graph as a PNG. Handy for slides, not needed for the tests themselves.

Building the Evaluable Architecture

The entry point is get_evaluable_architecture(). It scans your source tree for imports, builds an internal graph, and hands you an object you can query with rules. For a monorepo, you want to build it once per test session and reuse it — parsing the whole tree on every test gets slow fast.

tests/conftest.py
from pathlib import Path
import pytest
from pytestarch import get_evaluable_architecture
REPO_ROOT = Path(__file__).resolve().parent.parent
@pytest.fixture(scope="session")
def workspace_arch():
return get_evaluable_architecture(
str(REPO_ROOT),
str(REPO_ROOT),
exclusions=("*__pycache__*", "*.venv*", "*tests*", "*__init__.py"),
# Keep pydantic, aws_lambda_powertools, custom_core, *_models, ...
# in the graph so we can reason about cross-package imports.
exclude_external_libraries=False,
)

Two things worth calling out:

  • scope="session" — parse the tree once, share it across every architecture test. On a large monorepo, that’s the difference between a two-second run and a grumpy forty-second wait.
  • exclude_external_libraries=False — by default PyTestArch drops external imports from the graph. We need them here because in a uv workspace with a src/ layout and dashes in folder names, pytestarch treats sibling packages (custom_core, customer_models, order_models) as “external” too. Keeping them in the graph is what lets us write rules about them.

Writing the Rules

Rules use a chainable API that reads almost like plain English: modules that [match X] should [not] import modules that [match Y].

Rule 1: Models Only Depend on Pydantic and Stdlib

This one is expressed as a whitelist: “models must not import anything except this allow-list”. PyTestArch has a clause for exactly that — import_modules_except_modules_that().

tests/test_architecture.py
import sys
from pytestarch import EvaluableArchitecture, Rule
CUSTOMER_MODELS = r"pytestarch-demo\.customer-service\.customer-models\..*"
ORDER_MODELS = r"pytestarch-demo\.order-service\.order-models\..*"
ALLOWED_MODEL_DEPS = {"pydantic", *sys.stdlib_module_names}
def allowed_for_models(arch: EvaluableArchitecture) -> list[str]:
# `are_named` only accepts nodes that actually exist in the graph,
# so expand the allow-list to whatever is currently present.
return [m for m in arch.modules if m.split(".", 1)[0] in ALLOWED_MODEL_DEPS]
def test_customer_models_only_uses_pydantic_and_stdlib(workspace_arch):
rule = (
Rule()
.modules_that()
.have_name_matching(CUSTOMER_MODELS)
.should_not()
.import_modules_except_modules_that()
.are_named(allowed_for_models(workspace_arch))
)
rule.assert_applies(workspace_arch)

There’s a subtle bit here that tripped me up the first time: .are_named(...) only accepts names that exist as nodes in the graph. If you pass it "itertools" but nothing in your codebase actually imports itertools, pytestarch raises a ModuleNotPresentError. The allowed_for_models() helper fixes this by filtering the allow-list down to whatever modules are actually present in the graph.

Rule 2 & 3: Services Don’t Import Each Other

These are simple blacklists: “modules matching X must not import modules named Y”.

# tests/test_architecture.py (continued)
CUSTOMER_SERVICE = r"pytestarch-demo\.customer-service\..*"
ORDER_SERVICE = r"pytestarch-demo\.order-service\..*"
def test_customer_service_does_not_import_order_service(workspace_arch):
rule = (
Rule()
.modules_that()
.have_name_matching(CUSTOMER_SERVICE)
.should_not()
.import_modules_that()
.are_named(["order_models"])
)
rule.assert_applies(workspace_arch)
def test_order_service_does_not_import_customer_service(workspace_arch):
rule = (
Rule()
.modules_that()
.have_name_matching(ORDER_SERVICE)
.should_not()
.import_modules_that()
.are_named(["customer_models"])
)
rule.assert_applies(workspace_arch)
Why have_name_matching for subjects but are_named for objects?

PyTestArch represents each module as a node in a graph. The subject of a rule (the “modules that…”) is matched against the long internal module names PyTestArch builds from your file paths — things like pytestarch-demo.order-service.order-models.src.order_models.order. A regex (have_name_matching) is the natural fit.

The object of the rule (the “…import modules that”) is matched against the actual import names as they appear in Python source — order_models, customer_models, pydantic. Those are exact dotted names, so are_named with a concrete list is cleaner. Mixing the two keeps the rules short and readable.

Seeing It Fail

The demo repo is checked in with four intentionally-broken imports so the test suite fails out of the box — because a green test you’ve never seen red is a test you don’t trust.

FileOffending import
customer-models/.../customer.pyfrom custom_core import date_util
order-models/.../order.pyfrom aws_lambda_powertools import Logger
get-customer/.../lambda_function.pyfrom order_models.order import Order
create-order/.../lambda_function.pyfrom customer_models.customer import Customer

Running the suite:

Terminal window
uv sync
uv run pytest tests/ -v

Each violation produces a failure message that names the offending module and the rule it broke. Comment the marked imports back out and everything goes green.

Running It in CI

A test that only runs locally is a test that doesn’t run. Architecture tests belong in the same pipeline as your unit tests — same stage, same failure behaviour, blocking the same merge button.

Here’s a minimal GitHub Actions workflow:

.github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: astral-sh/setup-uv@v7
with:
python-version: "3.14"
- run: uv sync --all-packages --dev
- name: Run architecture tests
run: uv run pytest tests/ -v

A few practical tips once this is wired up:

  • Fail fast on architecture. Put the architecture tests in their own job (or at least their own pytest invocation with a marker) so they show up as a distinct check on the PR. “Architecture test failed” tells a reviewer more than “tests failed”.
  • Make violations actionable. When you add a new rule to an existing codebase, expect pre-existing violations. Either fix them in the same PR, or add them to a temporary xfail list with a linked ticket — don’t weaken the rule.
  • Treat rules like code. Rules live next to the code they constrain, they go through PR review, and they’re versioned. No separate “architecture document” that slowly goes out of date.

Where to Take This

The three rules in the demo are the tip of the iceberg. Things PyTestArch can also express:

  • Layered architectures: “controllers may import services, services may import repositories, repositories may not import anything application-specific”. Classic onion/hexagonal setups.
  • Public APIs: “modules outside mypackage must only import from mypackage.api, never from mypackage.internal”.
  • Dependency direction: “domain modules must not import infrastructure modules” — keeping your business logic free from framework imports.
  • Forbidden patterns: “no module should import requests directly — use the shared HTTP client”.

Each of those is just a few lines of code. And once you have one rule in place, adding the next one is trivial — the evaluable architecture is already built, the fixture is already wired, the CI job already runs.

For checks that go beyond import analysis, Python’s built-in ast module is a natural complement. Things pytestarch can’t express — enforcing that every public function in a package has a return type annotation, banning certain built-ins in specific modules, or verifying that all exception handlers re-raise — are straightforward to write as AST visitors. Same pattern: a pytest test, a fixture, a failure that blocks the PR. Tools like astroid (what pylint uses internally) can take you even further if you need type-aware analysis.

Wrapping Up

Architecture only survives if it’s tested. PyTestArch gives you a small, readable API to write your architecture rules as real pytest assertions — rules that would otherwise only live in people’s heads and code review comments. In a monorepo — where the walls between services are soft by default — that’s not a nice-to-have, it’s the difference between “we have microservices” and “we have a distributed monolith in a trench coat”.

The full demo repository is checked in with the violations already active, so you can clone it, run the tests, watch them fail, and then fix them one by one to see how the rules behave.

One honest caveat: I’m still pretty new to pytestarch and this post reflects my early exploration of the library. The workarounds for ModuleNotPresentError and the regex-vs-exact-name asymmetry were things I only figured out by hitting the wall, not by reading the docs cover to cover. There are almost certainly cleaner ways to express some of these rules — if you know one, I’d genuinely love to hear about it.

Resources