diff --git a/ai-assisted-coding-architecture/AGENTS.md b/ai-assisted-coding-architecture/AGENTS.md new file mode 100644 index 0000000000..5a67b475c5 --- /dev/null +++ b/ai-assisted-coding-architecture/AGENTS.md @@ -0,0 +1,31 @@ +# Orders service: instructions for coding agents + +## Layout + +- `orders/domain/`: pure business rules. No I/O, no framework imports. +- `orders/services/`: use cases. Depend on `domain` and on the `Protocol` + classes in `services/ports.py`, never on `adapters`. +- `orders/adapters/`: databases and network. May import `services` and + `domain`. + +## Invariants + +1. Imports point inward only: adapters -> services -> domain. +2. Domain dataclasses are frozen. +3. Absolute imports only (`from orders.domain.models import Order`). +4. Use `datetime.now(UTC)`, never `datetime.utcnow()`. +5. Use `pathlib.Path`, not `os.path`. +6. Before writing a helper, search `orders/` for an existing one. + +## Checks that must pass before you finish + +Run each of these from the project root: + +- `lint-imports` +- `ruff check . && ruff format --check .` +- `pytest` +- `pylint --disable=all --enable=duplicate-code orders` +- `vulture` + +Decisions live in `docs/adr/`. Read the accepted ADRs before changing a +boundary, and propose a new ADR instead of editing an accepted one. diff --git a/ai-assisted-coding-architecture/CLAUDE.md b/ai-assisted-coding-architecture/CLAUDE.md new file mode 100644 index 0000000000..43c994c2d3 --- /dev/null +++ b/ai-assisted-coding-architecture/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/ai-assisted-coding-architecture/README.md b/ai-assisted-coding-architecture/README.md new file mode 100644 index 0000000000..d8d82d82f0 --- /dev/null +++ b/ai-assisted-coding-architecture/README.md @@ -0,0 +1,34 @@ +# AI-Assisted Coding Architecture: Keep Python Code Coherent + +Sample code for the Real Python article +[AI-Assisted Coding Architecture: Keep Python Code Coherent](https://realpython.com/ai-assisted-coding-architecture/). + +A small layered `orders` package, with the guardrails that keep its +architecture intact: an `AGENTS.md` file, an import-linter contract, Ruff's +`banned-api` rule, an AST-based architecture test, duplicate-code detection +with Pylint, dead-code detection with Vulture, and an architecture decision +record. + +## Setup + +Python 3.11 or later is required. + +```console +$ python -m venv venv +$ source venv/bin/activate +(venv) $ python -m pip install -r requirements.txt +``` + +## Run the checks + +```console +(venv) $ lint-imports +(venv) $ ruff check . && ruff format --check . +(venv) $ pytest +(venv) $ pylint --disable=all --enable=duplicate-code orders +(venv) $ vulture +``` + +## License + +Distributed under the MIT license. diff --git a/ai-assisted-coding-architecture/docs/adr/0001-layered-architecture.md b/ai-assisted-coding-architecture/docs/adr/0001-layered-architecture.md new file mode 100644 index 0000000000..29d407a22e --- /dev/null +++ b/ai-assisted-coding-architecture/docs/adr/0001-layered-architecture.md @@ -0,0 +1,24 @@ +# 1. Use a layered architecture with enforced import direction + +Date: 2026-10-26 + +## Status + +Accepted + +## Context + +Contributors, human and automated, kept importing database code from the +domain layer. Reviews caught it late, and each fix touched several modules. + +## Decision + +Code lives in three layers: `orders.adapters`, `orders.services`, and +`orders.domain`. A layer may import only from layers below it. The rule is +enforced by an import-linter `layers` contract that runs in CI. + +## Consequences + +- Domain code can be tested without a database. +- New integrations go in `adapters`, so they can't leak into business rules. +- A cross-layer shortcut now fails CI instead of waiting for review. diff --git a/ai-assisted-coding-architecture/orders/__init__.py b/ai-assisted-coding-architecture/orders/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ai-assisted-coding-architecture/orders/adapters/__init__.py b/ai-assisted-coding-architecture/orders/adapters/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ai-assisted-coding-architecture/orders/adapters/sqlite_repo.py b/ai-assisted-coding-architecture/orders/adapters/sqlite_repo.py new file mode 100644 index 0000000000..fa2877e4c7 --- /dev/null +++ b/ai-assisted-coding-architecture/orders/adapters/sqlite_repo.py @@ -0,0 +1,35 @@ +import json +import sqlite3 +from decimal import Decimal + +from orders.domain.models import Order, OrderLine + + +class SqliteOrderRepository: + def __init__(self, connection: sqlite3.Connection) -> None: + self._db = connection + self._db.execute( + "CREATE TABLE IF NOT EXISTS orders " + "(id TEXT PRIMARY KEY, body TEXT)" + ) + + def save(self, order: Order) -> None: + body = json.dumps( + [[ln.sku, ln.quantity, str(ln.unit_price)] for ln in order.lines] + ) + self._db.execute( + "INSERT OR REPLACE INTO orders VALUES (?, ?)", + (order.order_id, body), + ) + + def get(self, order_id: str) -> Order | None: + row = self._db.execute( + "SELECT body FROM orders WHERE id = ?", (order_id,) + ).fetchone() + if row is None: + return None + lines = tuple( + OrderLine(sku, qty, Decimal(price)) + for sku, qty, price in json.loads(row[0]) + ) + return Order(order_id, lines) diff --git a/ai-assisted-coding-architecture/orders/domain/__init__.py b/ai-assisted-coding-architecture/orders/domain/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ai-assisted-coding-architecture/orders/domain/models.py b/ai-assisted-coding-architecture/orders/domain/models.py new file mode 100644 index 0000000000..c4abeb7f0e --- /dev/null +++ b/ai-assisted-coding-architecture/orders/domain/models.py @@ -0,0 +1,23 @@ +from dataclasses import dataclass +from decimal import Decimal + + +@dataclass(frozen=True) +class OrderLine: + sku: str + quantity: int + unit_price: Decimal + + @property + def total(self) -> Decimal: + return self.unit_price * self.quantity + + +@dataclass(frozen=True) +class Order: + order_id: str + lines: tuple[OrderLine, ...] + + @property + def total(self) -> Decimal: + return sum((line.total for line in self.lines), Decimal(0)) diff --git a/ai-assisted-coding-architecture/orders/domain/pricing.py b/ai-assisted-coding-architecture/orders/domain/pricing.py new file mode 100644 index 0000000000..5010ebf165 --- /dev/null +++ b/ai-assisted-coding-architecture/orders/domain/pricing.py @@ -0,0 +1,12 @@ +from decimal import Decimal + +from orders.domain.models import Order + +BULK_THRESHOLD = Decimal(100) +BULK_DISCOUNT = Decimal("0.10") + + +def discounted_total(order: Order) -> Decimal: + if order.total >= BULK_THRESHOLD: + return order.total * (1 - BULK_DISCOUNT) + return order.total diff --git a/ai-assisted-coding-architecture/orders/services/__init__.py b/ai-assisted-coding-architecture/orders/services/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ai-assisted-coding-architecture/orders/services/checkout.py b/ai-assisted-coding-architecture/orders/services/checkout.py new file mode 100644 index 0000000000..b2bd245d0f --- /dev/null +++ b/ai-assisted-coding-architecture/orders/services/checkout.py @@ -0,0 +1,12 @@ +from datetime import UTC, datetime + +from orders.domain.models import Order +from orders.domain.pricing import discounted_total +from orders.services.ports import OrderRepository + + +def place_order(order: Order, repo: OrderRepository) -> str: + """Save the order and return a receipt line.""" + repo.save(order) + placed_at = datetime.now(UTC).isoformat(timespec="seconds") + return f"{order.order_id}: {discounted_total(order)} at {placed_at}" diff --git a/ai-assisted-coding-architecture/orders/services/ports.py b/ai-assisted-coding-architecture/orders/services/ports.py new file mode 100644 index 0000000000..1923e3a149 --- /dev/null +++ b/ai-assisted-coding-architecture/orders/services/ports.py @@ -0,0 +1,9 @@ +from typing import Protocol + +from orders.domain.models import Order + + +class OrderRepository(Protocol): + def save(self, order: Order) -> None: ... + + def get(self, order_id: str) -> Order | None: ... diff --git a/ai-assisted-coding-architecture/pyproject.toml b/ai-assisted-coding-architecture/pyproject.toml new file mode 100644 index 0000000000..bba2d95550 --- /dev/null +++ b/ai-assisted-coding-architecture/pyproject.toml @@ -0,0 +1,49 @@ +[tool.ruff] +line-length = 79 +target-version = "py311" + +[tool.ruff.lint] +select = ["E", "F", "I", "PTH", "UP", "TID"] + +[tool.ruff.lint.flake8-tidy-imports] +ban-relative-imports = "all" + +[tool.ruff.lint.flake8-tidy-imports.banned-api] +"datetime.datetime.utcnow".msg = "Deprecated and naive. Use datetime.now(UTC)." +"datetime.datetime.utcfromtimestamp".msg = "Deprecated and naive. Use datetime.fromtimestamp(ts, timezone.utc)." +"random.seed".msg = "Inject a random.Random instance instead of seeding global state." + +[tool.importlinter] +root_package = "orders" +include_external_packages = true + +[[tool.importlinter.contracts]] +name = "Layers: adapters -> services -> domain" +type = "layers" +layers = ["orders.adapters", "orders.services", "orders.domain"] +broken_contract_guidance = """ +Imports must point down the stack. Pass data into domain code instead of +fetching it there, and make services depend on a Protocol in +orders/services/ports.py, not on an adapter. Do not add an ignore_imports +entry without a new ADR. +See docs/adr/0001-layered-architecture.md. +""" + +[[tool.importlinter.contracts]] +name = "Domain stays free of I/O libraries" +type = "forbidden" +source_modules = ["orders.domain"] +forbidden_modules = ["sqlite3", "json", "requests", "httpx"] +allow_indirect_imports = true +broken_contract_guidance = """ +Domain code is pure business logic. Move the I/O into an adapter, and have +a service pass the data in. +See docs/adr/0001-layered-architecture.md. +""" + +[tool.vulture] +paths = ["orders", "tests"] +min_confidence = 60 + +[tool.pytest.ini_options] +pythonpath = ["."] diff --git a/ai-assisted-coding-architecture/requirements.txt b/ai-assisted-coding-architecture/requirements.txt new file mode 100644 index 0000000000..e67f60f1ee --- /dev/null +++ b/ai-assisted-coding-architecture/requirements.txt @@ -0,0 +1,5 @@ +import-linter==2.15 +pylint==4.1.3 +pytest==9.1.1 +ruff==0.17.2 +vulture==2.16 diff --git a/ai-assisted-coding-architecture/tests/test_architecture.py b/ai-assisted-coding-architecture/tests/test_architecture.py new file mode 100644 index 0000000000..37fd2ed200 --- /dev/null +++ b/ai-assisted-coding-architecture/tests/test_architecture.py @@ -0,0 +1,35 @@ +"""Invariants that import-linter and Ruff can't express.""" + +import ast +from pathlib import Path + +DOMAIN = Path(__file__).parent.parent / "orders" / "domain" + + +def _is_frozen_dataclass(node: ast.ClassDef) -> bool | None: + """Return None if not a dataclass, else whether it is frozen.""" + for deco in node.decorator_list: + if isinstance(deco, ast.Name) and deco.id == "dataclass": + return False + if ( + isinstance(deco, ast.Call) + and getattr(deco.func, "id", None) == "dataclass" + ): + return any( + kw.arg == "frozen" + and isinstance(kw.value, ast.Constant) + and kw.value.value is True + for kw in deco.keywords + ) + return None + + +def test_domain_dataclasses_are_frozen(): + mutable = [] + for path in sorted(DOMAIN.rglob("*.py")): + tree = ast.parse(path.read_text(encoding="utf-8")) + for node in ast.walk(tree): + if isinstance(node, ast.ClassDef): + if _is_frozen_dataclass(node) is False: + mutable.append(f"{path.name}:{node.lineno} {node.name}") + assert not mutable, f"Domain dataclasses must be frozen: {mutable}" diff --git a/ai-assisted-coding-architecture/tests/test_checkout.py b/ai-assisted-coding-architecture/tests/test_checkout.py new file mode 100644 index 0000000000..b670f66be8 --- /dev/null +++ b/ai-assisted-coding-architecture/tests/test_checkout.py @@ -0,0 +1,14 @@ +import sqlite3 +from decimal import Decimal + +from orders.adapters.sqlite_repo import SqliteOrderRepository +from orders.domain.models import Order, OrderLine +from orders.services.checkout import place_order + + +def test_place_order_round_trip(): + repo = SqliteOrderRepository(sqlite3.connect(":memory:")) + order = Order("A1", (OrderLine("pen", 20, Decimal("6.00")),)) + receipt = place_order(order, repo) + assert receipt.startswith("A1: 108.0000 at ") + assert repo.get("A1") == order diff --git a/ai-assisted-coding-architecture/tests/test_duplicates.py b/ai-assisted-coding-architecture/tests/test_duplicates.py new file mode 100644 index 0000000000..664f348e8c --- /dev/null +++ b/ai-assisted-coding-architecture/tests/test_duplicates.py @@ -0,0 +1,51 @@ +"""Fail when two functions differ only in names and docstrings.""" + +import ast +from pathlib import Path + +MIN_NODES = 40 +PACKAGE = Path(__file__).parent.parent / "orders" + + +def normalize(func: ast.FunctionDef) -> str: + """Dump the body with local names replaced by numbered placeholders.""" + params = [ + arg.arg for arg in ast.walk(func.args) if isinstance(arg, ast.arg) + ] + local = set(params) | { + n.id + for n in ast.walk(func) + if isinstance(n, ast.Name) and isinstance(n.ctx, ast.Store) + } + placeholders = {name: number for number, name in enumerate(params)} + + class Rename(ast.NodeTransformer): + def visit_Name(self, node): + if node.id in local: + number = placeholders.setdefault(node.id, len(placeholders)) + return ast.Name(id=f"_{number}", ctx=node.ctx) + return node + + def visit_arg(self, node): + number = placeholders.setdefault(node.arg, len(placeholders)) + return ast.arg(arg=f"_{number}") + + body = func.body + if ast.get_docstring(func) is not None: + body = body[1:] + module = ast.Module(body=body, type_ignores=[]) + return ast.dump(Rename().visit(module)) + + +def test_no_renamed_duplicates(): + seen = {} + for path in sorted(PACKAGE.rglob("*.py")): + for node in ast.walk(ast.parse(path.read_text(encoding="utf-8"))): + if isinstance(node, ast.FunctionDef): + key = normalize(node) + if key.count("(") >= MIN_NODES: + seen.setdefault(key, []).append( + f"{path.name}:{node.lineno} {node.name}" + ) + clones = [places for places in seen.values() if len(places) > 1] + assert not clones, f"Same logic under different names: {clones}"