Enforcing Python Architecture Rules with PyTestArch
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.

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.pyThree rules we want to enforce:
*-modelspackages 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.customer-servicemust not importorder-servicecode.order-servicemust not importcustomer-servicecode.
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:
uv add --dev pytestarch pytestPyTestArch 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.
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 asrc/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().
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.
| File | Offending import |
|---|---|
customer-models/.../customer.py | from custom_core import date_util |
order-models/.../order.py | from aws_lambda_powertools import Logger |
get-customer/.../lambda_function.py | from order_models.order import Order |
create-order/.../lambda_function.py | from customer_models.customer import Customer |
Running the suite:
uv syncuv run pytest tests/ -vEach 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:
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/ -vA 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
pytestinvocation 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
xfaillist 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
mypackagemust only import frommypackage.api, never frommypackage.internal”. - Dependency direction: “domain modules must not import infrastructure modules” — keeping your business logic free from framework imports.
- Forbidden patterns: “no module should import
requestsdirectly — 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
- PyTestArch on GitHub
- PyTestArch documentation
- ArchUnit — the Java library PyTestArch is inspired by
- Demo repository