diff --git a/copier.yml b/copier.yml index c79e4de..f19c7da 100644 --- a/copier.yml +++ b/copier.yml @@ -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 diff --git a/template/.github/workflows/cicd.yaml b/template/.github/workflows/cicd.yaml index 3cac590..3981646 100644 --- a/template/.github/workflows/cicd.yaml +++ b/template/.github/workflows/cicd.yaml @@ -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: diff --git a/template/.pre-commit-config.yaml b/template/.pre-commit-config.yaml.jinja similarity index 94% rename from template/.pre-commit-config.yaml rename to template/.pre-commit-config.yaml.jinja index 159e90e..11f983d 100644 --- a/template/.pre-commit-config.yaml +++ b/template/.pre-commit-config.yaml.jinja @@ -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 %} diff --git a/template/AGENTS.md.jinja b/template/AGENTS.md.jinja index 6a7e4f1..17703ab 100644 --- a/template/AGENTS.md.jinja +++ b/template/AGENTS.md.jinja @@ -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 @@ -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 %} diff --git a/template/Makefile b/template/Makefile.jinja similarity index 94% rename from template/Makefile rename to template/Makefile.jinja index 7abc4b7..5ac554c 100644 --- a/template/Makefile +++ b/template/Makefile.jinja @@ -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 diff --git a/template/README.md.jinja b/template/README.md.jinja index 6f89cce..7ed14cd 100644 --- a/template/README.md.jinja +++ b/template/README.md.jinja @@ -8,6 +8,7 @@ Original repository: [https://github.com/{{ github_username }}/{{ project_slug }}](https://github.com/{{ github_username }}/{{ project_slug }}) +{% if include_hexagonal -%} # Architecture @@ -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 diff --git a/template/pyproject.toml.jinja b/template/pyproject.toml.jinja index 5086a5c..9e7aaa5 100644 --- a/template/pyproject.toml.jinja +++ b/template/pyproject.toml.jinja @@ -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", @@ -107,6 +109,7 @@ docs = [ "pydata-sphinx-theme>=0.17.1", ] +{% if include_hexagonal %} [tool.importlinter] root_packages = ["{{ project_slug }}"] include_external_packages = true @@ -150,3 +153,4 @@ forbidden_modules = [ "requests", "sqlalchemy", ] +{% endif %} diff --git a/template/src/{{ project_slug }}/adapters/__init__.py.jinja b/template/src/{{ project_slug }}/{% if include_hexagonal %}adapters{% endif %}/__init__.py.jinja similarity index 100% rename from template/src/{{ project_slug }}/adapters/__init__.py.jinja rename to template/src/{{ project_slug }}/{% if include_hexagonal %}adapters{% endif %}/__init__.py.jinja diff --git a/template/src/{{ project_slug }}/adapters/in_memory_note_repository.py.jinja b/template/src/{{ project_slug }}/{% if include_hexagonal %}adapters{% endif %}/in_memory_note_repository.py.jinja similarity index 100% rename from template/src/{{ project_slug }}/adapters/in_memory_note_repository.py.jinja rename to template/src/{{ project_slug }}/{% if include_hexagonal %}adapters{% endif %}/in_memory_note_repository.py.jinja diff --git a/template/src/{{ project_slug }}/domain/__init__.py.jinja b/template/src/{{ project_slug }}/{% if include_hexagonal %}domain{% endif %}/__init__.py.jinja similarity index 100% rename from template/src/{{ project_slug }}/domain/__init__.py.jinja rename to template/src/{{ project_slug }}/{% if include_hexagonal %}domain{% endif %}/__init__.py.jinja diff --git a/template/src/{{ project_slug }}/domain/errors.py.jinja b/template/src/{{ project_slug }}/{% if include_hexagonal %}domain{% endif %}/errors.py.jinja similarity index 100% rename from template/src/{{ project_slug }}/domain/errors.py.jinja rename to template/src/{{ project_slug }}/{% if include_hexagonal %}domain{% endif %}/errors.py.jinja diff --git a/template/src/{{ project_slug }}/domain/models.py.jinja b/template/src/{{ project_slug }}/{% if include_hexagonal %}domain{% endif %}/models.py.jinja similarity index 100% rename from template/src/{{ project_slug }}/domain/models.py.jinja rename to template/src/{{ project_slug }}/{% if include_hexagonal %}domain{% endif %}/models.py.jinja diff --git a/template/src/{{ project_slug }}/domain/ports.py.jinja b/template/src/{{ project_slug }}/{% if include_hexagonal %}domain{% endif %}/ports.py.jinja similarity index 100% rename from template/src/{{ project_slug }}/domain/ports.py.jinja rename to template/src/{{ project_slug }}/{% if include_hexagonal %}domain{% endif %}/ports.py.jinja diff --git a/template/src/{{ project_slug }}/services/__init__.py.jinja b/template/src/{{ project_slug }}/{% if include_hexagonal %}services{% endif %}/__init__.py.jinja similarity index 100% rename from template/src/{{ project_slug }}/services/__init__.py.jinja rename to template/src/{{ project_slug }}/{% if include_hexagonal %}services{% endif %}/__init__.py.jinja diff --git a/template/src/{{ project_slug }}/services/note_service.py.jinja b/template/src/{{ project_slug }}/{% if include_hexagonal %}services{% endif %}/note_service.py.jinja similarity index 100% rename from template/src/{{ project_slug }}/services/note_service.py.jinja rename to template/src/{{ project_slug }}/{% if include_hexagonal %}services{% endif %}/note_service.py.jinja diff --git a/template/tests/test_example_notes.py.jinja b/template/tests/{% if include_hexagonal %}test_example_notes.py{% endif %}.jinja similarity index 100% rename from template/tests/test_example_notes.py.jinja rename to template/tests/{% if include_hexagonal %}test_example_notes.py{% endif %}.jinja diff --git a/tests/test_template.py b/tests/test_template.py index df6a777..2bdf570 100644 --- a/tests/test_template.py +++ b/tests/test_template.py @@ -3,6 +3,7 @@ import re import subprocess from pathlib import Path +from types import SimpleNamespace import pytest @@ -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", @@ -44,29 +50,34 @@ 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() @@ -74,7 +85,7 @@ def test_docs_scaffold_renders(project_path): 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") @@ -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, ) @@ -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, ) @@ -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, ) @@ -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, ) @@ -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, ) @@ -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 @@ -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, ) @@ -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 @@ -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, )