From 68727e737d3ce4466bb9fa147267fdbfcd8896cf Mon Sep 17 00:00:00 2001 From: Marius Helf Date: Thu, 11 Jun 2026 22:13:38 +0200 Subject: [PATCH] feat: make hexagonal scaffolding optional via copier question Add an `include_hexagonal` boolean question (default true). When false, the generated project gets only the flat src layout (main/settings/utils) and none of the hexagonal machinery. The scaffolding is gated entirely at generation time: - The domain/services/adapters directories and the example test use Jinja conditional names (`{% if include_hexagonal %}...{% endif %}`), so copier skips them when the answer is false. - pyproject.toml.jinja wraps the import-linter dev dependency and the `[tool.importlinter]` contracts in the same conditional. - AGENTS.md.jinja and README.md.jinja drop their Architecture sections, and AGENTS.md's lint note no longer mentions import-linter. - .pre-commit-config.yaml and Makefile become .jinja templates: the lint-imports pre-commit hook is omitted, and the Makefile's `import_lint` target becomes a no-op (kept so CI's `make import_lint` step stays valid in both modes). cicd.yaml is left untemplated to avoid clashing with GitHub's `${{ }}` expressions. The template's test suite is now parametrised over both answers, so the hexagonal and flat layouts are each generated and verified end to end (lint, test, main, pre-commit). --- copier.yml | 8 +++ template/.github/workflows/cicd.yaml | 2 +- ...fig.yaml => .pre-commit-config.yaml.jinja} | 2 + template/AGENTS.md.jinja | 8 ++- template/{Makefile => Makefile.jinja} | 6 +- template/README.md.jinja | 2 + template/pyproject.toml.jinja | 5 +- .../__init__.py.jinja | 0 .../in_memory_note_repository.py.jinja | 0 .../__init__.py.jinja | 0 .../errors.py.jinja | 0 .../models.py.jinja | 0 .../ports.py.jinja | 0 .../__init__.py.jinja | 0 .../note_service.py.jinja | 0 ... %}test_example_notes.py{% endif %}.jinja} | 0 tests/test_template.py | 65 +++++++++++-------- 17 files changed, 66 insertions(+), 32 deletions(-) rename template/{.pre-commit-config.yaml => .pre-commit-config.yaml.jinja} (94%) rename template/{Makefile => Makefile.jinja} (83%) rename template/src/{{ project_slug }}/{adapters => {% if include_hexagonal %}adapters{% endif %}}/__init__.py.jinja (100%) rename template/src/{{ project_slug }}/{adapters => {% if include_hexagonal %}adapters{% endif %}}/in_memory_note_repository.py.jinja (100%) rename template/src/{{ project_slug }}/{domain => {% if include_hexagonal %}domain{% endif %}}/__init__.py.jinja (100%) rename template/src/{{ project_slug }}/{domain => {% if include_hexagonal %}domain{% endif %}}/errors.py.jinja (100%) rename template/src/{{ project_slug }}/{domain => {% if include_hexagonal %}domain{% endif %}}/models.py.jinja (100%) rename template/src/{{ project_slug }}/{domain => {% if include_hexagonal %}domain{% endif %}}/ports.py.jinja (100%) rename template/src/{{ project_slug }}/{services => {% if include_hexagonal %}services{% endif %}}/__init__.py.jinja (100%) rename template/src/{{ project_slug }}/{services => {% if include_hexagonal %}services{% endif %}}/note_service.py.jinja (100%) rename template/tests/{test_example_notes.py.jinja => {% if include_hexagonal %}test_example_notes.py{% endif %}.jinja} (100%) diff --git a/copier.yml b/copier.yml index 5b35989..b3d3168 100644 --- a/copier.yml +++ b/copier.yml @@ -59,3 +59,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 4ce23f8..5da7438 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 83% rename from template/Makefile rename to template/Makefile.jinja index 87642d9..4504479 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 66d33f0..c6b2d0c 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 0727cd2..2b472d6 100644 --- a/template/pyproject.toml.jinja +++ b/template/pyproject.toml.jinja @@ -94,12 +94,14 @@ type = "github" [dependency-groups] dev = [ +{%- if include_hexagonal %} "import-linter>=2.0", +{%- endif %} "pytest>=8.0", "ruff>=0.15", "ty>=0.0.17", ] - +{% if include_hexagonal %} [tool.importlinter] root_packages = ["{{ project_slug }}"] include_external_packages = true @@ -143,3 +145,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 549d1c4..45a73d9 100644 --- a/tests/test_template.py +++ b/tests/test_template.py @@ -2,6 +2,7 @@ import subprocess from pathlib import Path +from types import SimpleNamespace import pytest @@ -21,14 +22,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", @@ -39,26 +45,31 @@ 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_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, ) @@ -67,11 +78,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, ) @@ -80,11 +91,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, ) @@ -93,11 +104,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, ) @@ -106,17 +117,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, )