Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion copier.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,19 @@ project_name:
project_slug:
type: str
help: "Python package name (used for imports, directory name, etc.)"
default: "{{ project_name | lower | replace(' ', '_') | replace('-', '_') | replace('.', '_') }}"
# Allowlist sanitisation: lowercase, collapse every run of non-alphanumeric
# characters (spaces, hyphens, dots, apostrophes, …) into a single
# underscore, then trim leading/trailing underscores. A blocklist of
# individual replaces would leak characters such as the apostrophe in the
# default project_name ("…'s new project").
default: "{{ project_name | lower | regex_replace('[^a-z0-9]+', '_') | trim('_') }}"
# Reject anything that is not a valid Python package / import name, whether it
# comes from the default above or is typed by the user.
validator: >-
{% if not (project_slug | regex_search('^[a-z_][a-z0-9_]*$')) %}
project_slug must be a valid Python package name: lowercase letters, digits
and underscores only, and it may not start with a digit.
{% endif %}

project_short_description:
type: str
Expand Down
2 changes: 2 additions & 0 deletions template/.github/workflows/cicd.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ jobs:
run: uv run --no-sources ruff check src tests
- name: ty
run: make ty
- name: import-linter
run: uv run --no-sources lint-imports
test:
runs-on: ubuntu-latest
strategy:
Expand Down
5 changes: 5 additions & 0 deletions template/.pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,8 @@ repos:
language: system
files: ^src/.*\.py$
exclude: ^src/legacy/.*\.py$
- id: lint_imports
name: lint imports
entry: uv run lint-imports
language: system
pass_filenames: false
22 changes: 0 additions & 22 deletions template/AGENTS.md

This file was deleted.

68 changes: 68 additions & 0 deletions template/AGENTS.md.jinja
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# Project Guidelines

## Package Management

Use `uv` for all dependency management (not `pip`). Examples:
- `uv add <package>` to add a dependency
- `uv pip install -e .` to install the project
- `uv run <command>` to run commands in the project environment

## Git Conventions

Use semantic commit messages:

- `feat:` new feature
- `fix:` bug fix (something was actually broken)
- `docs:` documentation changes
- `style:` formatting, missing semicolons, etc. (no code change)
- `refactor:` code restructuring without changing behavior
- `test:` adding or updating tests
- `chore:` maintenance tasks, dependency updates, CI config, cleanup of things that work but are unnecessary

Do NOT add "Co-Authored-By" or any AI attribution trailers to commit messages.

## Architecture

This project follows a hexagonal (ports-and-adapters) architecture. The
dependency rule points inward: outer layers may import inner layers, never the
reverse.

- **Flow:** driving adapter (`main.py`) → application service (use case) →
driven adapters (behind ports) → result.
- Driven adapters (databases, HTTP clients, external APIs, ...) sit behind ports
declared in `{{ project_slug }}.domain.ports`, so implementations can be
swapped without touching the core.
- Ports are declared as abstract base classes (`abc.ABC` with
`@abstractmethod`). **Every class that implements a port inherits from it** —
this applies to production adapters *and* test doubles (e.g.
`class InMemoryNoteRepository(NoteRepository):`,
`class FakeNoteRepository(NoteRepository):`). The ABC enforces the contract at
instantiation time (a subclass that misses an abstract method cannot be
instantiated) and makes the port↔adapter relationship discoverable in IDEs
("go to definition" jumps to the contract). The one common exception is a
one-line `unittest.mock.MagicMock(spec=NoteRepository)`, which enforces
conformance via `spec=`.

Layer rules are enforced by `import-linter` contracts in `pyproject.toml`
(`make lint` runs them). The package layout is:

- `{{ project_slug }}.domain` — entities, value objects, errors, and driven
ports (the inner core; no third-party infrastructure imports).
- `{{ project_slug }}.services` — application services / use cases (depend only
on `domain`).
- `{{ project_slug }}.adapters` — driven adapter implementations of the ports
declared in `{{ project_slug }}.domain.ports`.
- `{{ project_slug }}.main` — the composition root and driving adapter: wires
concrete adapters into the services at startup. As the composition root it is
the one module allowed to import from every layer.

The `domain`, `services`, and `adapters` packages ship with a small,
clearly-marked example slice (a `Note` entity, a `NoteRepository` port, an
in-memory adapter, and a `NoteService`). Search for `# --- example` and delete
those blocks once you start modelling your own domain.

## Testing & Linting

- `make test` runs the test suite.
- `make lint` runs ruff, the `ty` type checker, and the `import-linter`
architecture contracts.
7 changes: 5 additions & 2 deletions template/Makefile
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
SHELL := /bin/bash

.PHONY: sync clean test test-unit-tests test-doctest build ty ruff type_check lint pre_commit format
.PHONY: sync clean test test-unit-tests test-doctest build ty ruff import_lint type_check lint pre_commit format

sync:
uv sync --all-extras
Expand Down Expand Up @@ -32,9 +32,12 @@ ruff:
uv run --no-sources ruff format --check --target-version py312 src tests
uv run --no-sources ruff check --fix --exit-non-zero-on-fix src tests

import_lint:
uv run --no-sources lint-imports

type_check: ty

lint: ruff type_check
lint: ruff type_check import_lint

pre_commit:
pre-commit run --all-files
Expand Down
8 changes: 8 additions & 0 deletions template/README.md.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@
Original repository: [https://github.com/{{ github_username }}/{{ project_slug }}](https://github.com/{{ github_username }}/{{ project_slug }})


# Architecture

This project follows a [hexagonal (ports-and-adapters) architecture](https://en.wikipedia.org/wiki/Hexagonal_architecture_(software)),
organised into `domain`, `services`, and `adapters` layers. The layer
boundaries are enforced by [import-linter](https://import-linter.readthedocs.io/);
run `make lint` to check them. See [`AGENTS.md`](AGENTS.md) for the full rules.


# Installation

```bash
Expand Down
45 changes: 45 additions & 0 deletions template/pyproject.toml.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ type = "github"

[dependency-groups]
dev = [
"import-linter>=2.0",
"pytest>=8.0",
"ruff>=0.15",
"ty>=0.0.17",
Expand All @@ -105,3 +106,47 @@ docs = [
"sphinx-autobuild>=2024.4",
"pydata-sphinx-theme>=0.17.1",
]

[tool.importlinter]
root_packages = ["{{ project_slug }}"]
include_external_packages = true
exclude_type_checking_imports = true

[[tool.importlinter.contracts]]
name = "Enforce hexagonal layers"
type = "layers"
# Outer layers may import inner layers, never the reverse. `main.py` (the
# composition root / driving adapter) is intentionally NOT a layer here, so it
# is free to import every layer in order to wire adapters into services.
layers = [
"adapters",
"services",
"domain",
]
containers = ["{{ project_slug }}"]

[[tool.importlinter.contracts]]
name = "Domain stays free of infrastructure"
type = "forbidden"
source_modules = [
"{{ project_slug }}.domain",
]
# List the infrastructure packages your project depends on so the domain core
# can never import them. Extend this list as you add dependencies.
forbidden_modules = [
"httpx",
"requests",
"sqlalchemy",
]

[[tool.importlinter.contracts]]
name = "Application services depend only on ports, not infrastructure"
type = "forbidden"
source_modules = [
"{{ project_slug }}.services",
]
forbidden_modules = [
"httpx",
"requests",
"sqlalchemy",
]
7 changes: 7 additions & 0 deletions template/src/{{ project_slug }}/adapters/__init__.py.jinja
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
"""Driven adapters: concrete implementations of domain ports.

Adapters connect the application to the outside world (databases, HTTP APIs,
LLM providers, ...). They may import ``domain`` (to implement its ports) but
must not import ``services``, and adapters must not import one another -- they
meet only through the domain ports and the composition root (``main.py``).
"""
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
"""In-memory implementation of the example ``NoteRepository`` port."""

# --- example ---------------------------------------------------------------
from {{ project_slug }}.domain.errors import NoteNotFoundError
from {{ project_slug }}.domain.models import Note
from {{ project_slug }}.domain.ports import NoteRepository


class InMemoryNoteRepository(NoteRepository):
"""A dependency-free ``NoteRepository`` backed by a dict.

Inherits from the ``NoteRepository`` ABC, which enforces that every abstract
method is implemented before the class can be instantiated.
"""

def __init__(self) -> None:
self._notes: dict[str, Note] = {}

def save(self, note: Note) -> None:
"""Store the note, overwriting any existing note with the same id."""
self._notes[note.id] = note

def get(self, note_id: str) -> Note:
"""Return the stored note, or raise ``NoteNotFoundError``."""
try:
return self._notes[note_id]
except KeyError as exc:
raise NoteNotFoundError(note_id) from exc


# --- end example -----------------------------------------------------------
7 changes: 7 additions & 0 deletions template/src/{{ project_slug }}/domain/__init__.py.jinja
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
"""Domain layer: entities, value objects, errors, and driven ports.

The inner core of the hexagonal architecture. Code here must not import any
third-party infrastructure (web frameworks, database drivers, HTTP clients,
LLM SDKs, ...). The ``import-linter`` contracts in ``pyproject.toml`` enforce
this; run ``make lint`` to check.
"""
13 changes: 13 additions & 0 deletions template/src/{{ project_slug }}/domain/errors.py.jinja
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
"""Domain-specific error types."""


class DomainError(Exception):
"""Base class for all errors raised by the domain layer."""


# --- example ---------------------------------------------------------------
class NoteNotFoundError(DomainError):
"""Raised when a requested note does not exist."""


# --- end example -----------------------------------------------------------
18 changes: 18 additions & 0 deletions template/src/{{ project_slug }}/domain/models.py.jinja
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
"""Domain entities and value objects."""

# --- example ---------------------------------------------------------------
# Everything marked "example" across the domain/services/adapters packages (and
# the matching tests) is a seed that demonstrates the hexagonal pattern end to
# end. Delete it once you start modelling your own domain.
from dataclasses import dataclass


@dataclass(frozen=True)
class Note:
"""An immutable note entity, identified by ``id``."""

id: str
text: str


# --- end example -----------------------------------------------------------
29 changes: 29 additions & 0 deletions template/src/{{ project_slug }}/domain/ports.py.jinja
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"""Driven ports: interfaces the application depends on, implemented by adapters.

Ports are declared as abstract base classes (:class:`abc.ABC` with
:func:`abc.abstractmethod`). Every adapter inherits from the port it implements
(e.g. ``class InMemoryNoteRepository(NoteRepository): ...``); ``abc`` then
enforces -- at instantiation time -- that the adapter implements every abstract
method. The explicit base class also makes the port-adapter relationship
discoverable in IDEs ("go to definition" jumps to the contract).
"""

# --- example ---------------------------------------------------------------
from abc import ABC, abstractmethod

from {{ project_slug }}.domain.models import Note


class NoteRepository(ABC):
"""Persistence port for :class:`Note` entities."""

@abstractmethod
def save(self, note: Note) -> None:
"""Store ``note``, overwriting any existing note with the same id."""

@abstractmethod
def get(self, note_id: str) -> Note:
"""Return the note with ``note_id`` or raise ``NoteNotFoundError``."""


# --- end example -----------------------------------------------------------
7 changes: 7 additions & 0 deletions template/src/{{ project_slug }}/services/__init__.py.jinja
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
"""Application services / use cases.

Orchestrates domain objects to fulfil application behaviour. Depends only on
``domain`` (entities and ports) -- never on ``adapters`` or infrastructure.
Inject adapters through port-typed parameters so use cases stay testable with
in-memory fakes.
"""
27 changes: 27 additions & 0 deletions template/src/{{ project_slug }}/services/note_service.py.jinja
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
"""Application service exercising the example ``NoteRepository`` port."""

# --- example ---------------------------------------------------------------
from {{ project_slug }}.domain.models import Note
from {{ project_slug }}.domain.ports import NoteRepository


class NoteService:
"""A use case that records and retrieves notes via a repository port."""

def __init__(self, repository: NoteRepository) -> None:
# Depend on the port, not a concrete adapter: the service never knows
# whether ``repository`` is in-memory, a database, or a test fake.
self._repository = repository

def record(self, note_id: str, text: str) -> Note:
"""Create and persist a note, returning the stored entity."""
note = Note(id=note_id, text=text)
self._repository.save(note)
return note

def read(self, note_id: str) -> Note:
"""Return a previously stored note."""
return self._repository.get(note_id)


# --- end example -----------------------------------------------------------
27 changes: 27 additions & 0 deletions template/tests/test_example_notes.py.jinja
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
"""Example tests for the seed hexagonal slice. Delete with the example code."""

# --- example ---------------------------------------------------------------
import pytest

from {{ project_slug }}.adapters.in_memory_note_repository import (
InMemoryNoteRepository,
)
from {{ project_slug }}.domain.errors import NoteNotFoundError
from {{ project_slug }}.services.note_service import NoteService


def test_record_and_read_round_trips_a_note():
"""A recorded note can be read back through the service and adapter."""
service = NoteService(InMemoryNoteRepository())
service.record("n1", "hello")
assert service.read("n1").text == "hello"


def test_reading_unknown_note_raises_domain_error():
"""Reading a missing note surfaces the domain-specific error."""
service = NoteService(InMemoryNoteRepository())
with pytest.raises(NoteNotFoundError):
service.read("missing")


# --- end example -----------------------------------------------------------
Loading
Loading