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
8 changes: 8 additions & 0 deletions copier.yml
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,11 @@ license:
- GPL-3.0-or-later
- LicenseRef-Proprietary
default: MIT

include_hexagonal:
type: bool
help: >-
Scaffold a hexagonal (ports-and-adapters) architecture? Adds domain/services/
adapters layers with an example slice, plus import-linter contracts that
enforce the layer boundaries.
default: true
2 changes: 1 addition & 1 deletion template/.github/workflows/cicd.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ jobs:
- name: ty
run: make ty
- name: import-linter
run: uv run --no-sources lint-imports
run: make import_lint
test:
runs-on: ubuntu-latest
strategy:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,10 @@ repos:
language: system
files: ^src/.*\.py$
exclude: ^src/legacy/.*\.py$
{%- if include_hexagonal %}
- id: lint_imports
name: lint imports
entry: uv run lint-imports
language: system
pass_filenames: false
{%- endif %}
8 changes: 6 additions & 2 deletions template/AGENTS.md.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ Use semantic commit messages:
- `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.

{% if include_hexagonal %}
## Architecture

This project follows a hexagonal (ports-and-adapters) architecture. The
Expand Down Expand Up @@ -60,9 +60,13 @@ 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.

{% endif %}
## Testing & Linting

- `make test` runs the test suite.
{%- if include_hexagonal %}
- `make lint` runs ruff, the `ty` type checker, and the `import-linter`
architecture contracts.
{%- else %}
- `make lint` runs ruff and the `ty` type checker.
{%- endif %}
6 changes: 5 additions & 1 deletion template/Makefile → template/Makefile.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,15 @@ ruff:
uv run --no-sources ruff check --fix --exit-non-zero-on-fix src tests

import_lint:
{%- if include_hexagonal %}
uv run --no-sources lint-imports
{%- else %}
@echo "import-linter is not configured (no hexagonal scaffolding)."
{%- endif %}

type_check: ty

lint: ruff type_check import_lint
lint: ruff type_check{% if include_hexagonal %} import_lint{% endif %}

pre_commit:
pre-commit run --all-files
Expand Down
2 changes: 2 additions & 0 deletions template/README.md.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

Original repository: [https://github.com/{{ github_username }}/{{ project_slug }}](https://github.com/{{ github_username }}/{{ project_slug }})

{% if include_hexagonal -%}

# Architecture

Expand All @@ -16,6 +17,7 @@ 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.

{% endif -%}

# Installation

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

[dependency-groups]
dev = [
{%- if include_hexagonal %}
"import-linter>=2.0",
{%- endif %}
"pytest>=8.0",
"ruff>=0.15",
"ty>=0.0.17",
Expand All @@ -107,6 +109,7 @@ docs = [
"pydata-sphinx-theme>=0.17.1",
]

{% if include_hexagonal %}
[tool.importlinter]
root_packages = ["{{ project_slug }}"]
include_external_packages = true
Expand Down Expand Up @@ -150,3 +153,4 @@ forbidden_modules = [
"requests",
"sqlalchemy",
]
{% endif %}
79 changes: 45 additions & 34 deletions tests/test_template.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import re
import subprocess
from pathlib import Path
from types import SimpleNamespace

import pytest

Expand All @@ -26,14 +27,19 @@
}


@pytest.fixture(scope="session")
def project_path(tmp_path_factory):
"""Generate a project from the template and install its dependencies."""
dst = tmp_path_factory.mktemp("generated")
@pytest.fixture(scope="session", params=[True, False], ids=["hexagonal", "flat"])
def project(tmp_path_factory, request):
"""Generate a project from the template and install its dependencies.

Parametrised over the ``include_hexagonal`` answer so that both the
hexagonal scaffolding and the plain layout are exercised end to end.
"""
include_hexagonal = request.param
dst = tmp_path_factory.mktemp("hex" if include_hexagonal else "flat")
copier.run_copy(
src_path=str(TEMPLATE_ROOT),
dst_path=str(dst),
data=COPIER_DATA,
data={**COPIER_DATA, "include_hexagonal": include_hexagonal},
defaults=True,
unsafe=True,
vcs_ref="HEAD",
Expand All @@ -44,37 +50,42 @@ def project_path(tmp_path_factory):
check=True,
capture_output=True,
)
return dst
return SimpleNamespace(path=dst, hexagonal=include_hexagonal)


def test_template_renders(project):
"""Verify key files exist, and that hexagonal files appear only when asked."""
assert (project.path / "pyproject.toml").is_file()
assert (project.path / "README.md").is_file()
assert (project.path / "LICENSE").is_file()
assert (project.path / "Makefile").is_file()
assert (project.path / "src" / "test_project").is_dir()
assert (project.path / "src" / "test_project" / "__init__.py").is_file()
assert (project.path / "src" / "test_project" / "main.py").is_file()
assert (project.path / "tests" / "test_test_project.py").is_file()

def test_template_renders(project_path):
"""Verify that key files exist after rendering."""
assert (project_path / "pyproject.toml").is_file()
assert (project_path / "README.md").is_file()
assert (project_path / "LICENSE").is_file()
assert (project_path / "Makefile").is_file()
assert (project_path / "src" / "test_project").is_dir()
assert (project_path / "src" / "test_project" / "__init__.py").is_file()
assert (project_path / "src" / "test_project" / "main.py").is_file()
assert (project_path / "tests" / "test_test_project.py").is_file()
domain = project.path / "src" / "test_project" / "domain"
example_test = project.path / "tests" / "test_example_notes.py"
assert domain.is_dir() == project.hexagonal
assert example_test.is_file() == project.hexagonal


def test_docs_scaffold_renders(project_path):
def test_docs_scaffold_renders(project):
"""Verify the documentation scaffold is present and placeholders resolved.

The docs scaffold must ship complete so a generated project builds its
Sphinx site immediately; this checks the key entry points exist and that
the templated identity made it into conf.py and index.md.
"""
source = project_path / "docs" / "source"
source = project.path / "docs" / "source"
assert (source / "conf.py").is_file()
assert (source / "index.md").is_file()
assert (source / "reference" / "python-api.md").is_file()
# Meta-layer vendored verbatim into contributing/.
assert (source / "contributing" / "voice.md").is_file()
assert (source / "contributing" / "documentation_guide.md").is_file()
# Drift-tripwire harness.
assert (project_path / "tests" / "docs" / "test_doc_claims.py").is_file()
assert (project.path / "tests" / "docs" / "test_doc_claims.py").is_file()

# Copier placeholders must be resolved, not left verbatim.
conf = (source / "conf.py").read_text(encoding="utf-8")
Expand All @@ -86,11 +97,11 @@ def test_docs_scaffold_renders(project_path):
assert "{{" not in index


def test_generated_tests_pass(project_path):
def test_generated_tests_pass(project):
"""Run pytest in the generated project and verify it passes."""
result = subprocess.run(
["uv", "run", "pytest"],
cwd=project_path,
cwd=project.path,
capture_output=True,
text=True,
)
Expand All @@ -99,11 +110,11 @@ def test_generated_tests_pass(project_path):
)


def test_main_executes(project_path):
def test_main_executes(project):
"""Run the generated project's main module."""
result = subprocess.run(
["uv", "run", "python", "-m", "test_project.main"],
cwd=project_path,
cwd=project.path,
capture_output=True,
text=True,
)
Expand All @@ -112,11 +123,11 @@ def test_main_executes(project_path):
)


def test_make_lint(project_path):
def test_make_lint(project):
"""Verify that `make lint` succeeds on the generated project."""
result = subprocess.run(
["make", "lint"],
cwd=project_path,
cwd=project.path,
capture_output=True,
text=True,
)
Expand All @@ -125,11 +136,11 @@ def test_make_lint(project_path):
)


def test_make_test(project_path):
def test_make_test(project):
"""Verify that `make test` succeeds on the generated project."""
result = subprocess.run(
["make", "test"],
cwd=project_path,
cwd=project.path,
capture_output=True,
text=True,
)
Expand All @@ -138,17 +149,17 @@ def test_make_test(project_path):
)


def test_pre_commit_passes(project_path):
def test_pre_commit_passes(project):
"""Verify that pre-commit hooks pass on the generated project."""
subprocess.run(
["git", "add", "."],
cwd=project_path,
cwd=project.path,
check=True,
capture_output=True,
)
result = subprocess.run(
["uvx", "pre-commit", "run", "--all-files"],
cwd=project_path,
cwd=project.path,
capture_output=True,
text=True,
)
Expand All @@ -157,7 +168,7 @@ def test_pre_commit_passes(project_path):
)


def test_make_docs_strict(project_path):
def test_make_docs_strict(project):
"""Verify `make docs-strict` builds the docs with zero warnings.

The scaffold is contracted to pass `sphinx-build -W` immediately after
Expand All @@ -166,7 +177,7 @@ def test_make_docs_strict(project_path):
"""
result = subprocess.run(
["make", "docs-strict"],
cwd=project_path,
cwd=project.path,
capture_output=True,
text=True,
)
Expand All @@ -175,7 +186,7 @@ def test_make_docs_strict(project_path):
)


def test_make_test_docs(project_path):
def test_make_test_docs(project):
"""Verify `make test-docs` passes on the freshly generated project.

The drift-tripwire suite in tests/docs/ must be green out of the box; its
Expand All @@ -184,7 +195,7 @@ def test_make_test_docs(project_path):
"""
result = subprocess.run(
["make", "test-docs"],
cwd=project_path,
cwd=project.path,
capture_output=True,
text=True,
)
Expand Down
Loading