From 77f1ac3c316049a164aa654af75197d2b6ab5b03 Mon Sep 17 00:00:00 2001 From: Marius Helf Date: Thu, 11 Jun 2026 21:32:39 +0200 Subject: [PATCH 1/3] feat: add hexagonal architecture scaffolding Restructure the generated package into a hexagonal (ports-and-adapters) layout with domain/services/adapters layers and main.py as the driving adapter / composition root. A small, clearly-marked ("# --- example") seed slice -- a Note entity, a NoteRepository port, an in-memory adapter, and a NoteService -- demonstrates the pattern end to end and is meant to be deleted once real domain modelling begins. Enforce the layering with import-linter contracts in pyproject.toml (layer ordering plus domain/services kept free of infrastructure), wired into pre-commit (lint-imports hook), the Makefile (folded into `make lint`), and the CI lint job. Document the architecture and the "every port implementation must explicitly inherit from its port" rule in AGENTS.md. --- template/.github/workflows/cicd.yaml | 2 + template/.pre-commit-config.yaml | 5 ++ template/AGENTS.md | 46 +++++++++++++++++++ template/Makefile | 7 ++- template/pyproject.toml.jinja | 45 ++++++++++++++++++ .../adapters/__init__.py.jinja | 7 +++ .../in_memory_note_repository.py.jinja | 31 +++++++++++++ .../domain/__init__.py.jinja | 7 +++ .../{{ project_slug }}/domain/errors.py.jinja | 13 ++++++ .../{{ project_slug }}/domain/models.py.jinja | 18 ++++++++ .../{{ project_slug }}/domain/ports.py.jinja | 28 +++++++++++ .../services/__init__.py.jinja | 7 +++ .../services/note_service.py.jinja | 27 +++++++++++ template/tests/test_example_notes.py.jinja | 27 +++++++++++ 14 files changed, 268 insertions(+), 2 deletions(-) create mode 100644 template/src/{{ project_slug }}/adapters/__init__.py.jinja create mode 100644 template/src/{{ project_slug }}/adapters/in_memory_note_repository.py.jinja create mode 100644 template/src/{{ project_slug }}/domain/__init__.py.jinja create mode 100644 template/src/{{ project_slug }}/domain/errors.py.jinja create mode 100644 template/src/{{ project_slug }}/domain/models.py.jinja create mode 100644 template/src/{{ project_slug }}/domain/ports.py.jinja create mode 100644 template/src/{{ project_slug }}/services/__init__.py.jinja create mode 100644 template/src/{{ project_slug }}/services/note_service.py.jinja create mode 100644 template/tests/test_example_notes.py.jinja diff --git a/template/.github/workflows/cicd.yaml b/template/.github/workflows/cicd.yaml index 38eac7e..4ce23f8 100644 --- a/template/.github/workflows/cicd.yaml +++ b/template/.github/workflows/cicd.yaml @@ -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: diff --git a/template/.pre-commit-config.yaml b/template/.pre-commit-config.yaml index 9dfbafd..159e90e 100644 --- a/template/.pre-commit-config.yaml +++ b/template/.pre-commit-config.yaml @@ -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 diff --git a/template/AGENTS.md b/template/AGENTS.md index 8ea4d97..76c5892 100644 --- a/template/AGENTS.md +++ b/template/AGENTS.md @@ -20,3 +20,49 @@ 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. + +## 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. +- **Every class that implements a port must explicitly inherit from it** — this + applies to production adapters *and* test doubles (e.g. + `class InMemoryNoteRepository(NoteRepository):`, + `class FakeNoteRepository(NoteRepository):`). Although Python `Protocol`s + support structural typing without inheritance, the explicit base class makes + the port↔adapter relationship discoverable in IDEs ("go to definition" jumps + to the contract) and signals intent to readers. Skipping the inheritance is + reserved for genuinely ad-hoc cases (e.g. a one-line + `unittest.mock.MagicMock(spec=NoteRepository)`, which enforces conformance via + `spec=`) and should be the exception, not the default. + +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. diff --git a/template/Makefile b/template/Makefile index f3899eb..87642d9 100644 --- a/template/Makefile +++ b/template/Makefile @@ -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 @@ -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 diff --git a/template/pyproject.toml.jinja b/template/pyproject.toml.jinja index f0ebe2e..0727cd2 100644 --- a/template/pyproject.toml.jinja +++ b/template/pyproject.toml.jinja @@ -94,7 +94,52 @@ type = "github" [dependency-groups] dev = [ + "import-linter>=2.0", "pytest>=8.0", "ruff>=0.15", "ty>=0.0.17", ] + +[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", +] diff --git a/template/src/{{ project_slug }}/adapters/__init__.py.jinja b/template/src/{{ project_slug }}/adapters/__init__.py.jinja new file mode 100644 index 0000000..2c30846 --- /dev/null +++ b/template/src/{{ project_slug }}/adapters/__init__.py.jinja @@ -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``). +""" diff --git a/template/src/{{ project_slug }}/adapters/in_memory_note_repository.py.jinja b/template/src/{{ project_slug }}/adapters/in_memory_note_repository.py.jinja new file mode 100644 index 0000000..90b4bf1 --- /dev/null +++ b/template/src/{{ project_slug }}/adapters/in_memory_note_repository.py.jinja @@ -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. + + Explicitly inherits from ``NoteRepository`` so the port-adapter relationship + is discoverable; the ``Protocol`` would also match structurally without it. + """ + + 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 ----------------------------------------------------------- diff --git a/template/src/{{ project_slug }}/domain/__init__.py.jinja b/template/src/{{ project_slug }}/domain/__init__.py.jinja new file mode 100644 index 0000000..fc36a70 --- /dev/null +++ b/template/src/{{ project_slug }}/domain/__init__.py.jinja @@ -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. +""" diff --git a/template/src/{{ project_slug }}/domain/errors.py.jinja b/template/src/{{ project_slug }}/domain/errors.py.jinja new file mode 100644 index 0000000..460584d --- /dev/null +++ b/template/src/{{ project_slug }}/domain/errors.py.jinja @@ -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 ----------------------------------------------------------- diff --git a/template/src/{{ project_slug }}/domain/models.py.jinja b/template/src/{{ project_slug }}/domain/models.py.jinja new file mode 100644 index 0000000..cd546a7 --- /dev/null +++ b/template/src/{{ project_slug }}/domain/models.py.jinja @@ -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 ----------------------------------------------------------- diff --git a/template/src/{{ project_slug }}/domain/ports.py.jinja b/template/src/{{ project_slug }}/domain/ports.py.jinja new file mode 100644 index 0000000..3456d89 --- /dev/null +++ b/template/src/{{ project_slug }}/domain/ports.py.jinja @@ -0,0 +1,28 @@ +"""Driven ports: interfaces the application depends on, implemented by adapters. + +Ports are declared as :class:`typing.Protocol` classes. Although Protocols +support structural typing, every adapter should *explicitly* inherit from the +port it implements (e.g. ``class InMemoryNoteRepository(NoteRepository): ...``). +The explicit base class makes the port-adapter relationship discoverable in +IDEs ("go to definition" jumps to the contract) and signals intent to readers. +""" + +# --- example --------------------------------------------------------------- +from typing import Protocol + +from {{ project_slug }}.domain.models import Note + + +class NoteRepository(Protocol): + """Persistence port for :class:`Note` entities.""" + + def save(self, note: Note) -> None: + """Store ``note``, overwriting any existing note with the same id.""" + ... + + def get(self, note_id: str) -> Note: + """Return the note with ``note_id`` or raise ``NoteNotFoundError``.""" + ... + + +# --- end example ----------------------------------------------------------- diff --git a/template/src/{{ project_slug }}/services/__init__.py.jinja b/template/src/{{ project_slug }}/services/__init__.py.jinja new file mode 100644 index 0000000..3d2537d --- /dev/null +++ b/template/src/{{ project_slug }}/services/__init__.py.jinja @@ -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. +""" diff --git a/template/src/{{ project_slug }}/services/note_service.py.jinja b/template/src/{{ project_slug }}/services/note_service.py.jinja new file mode 100644 index 0000000..d31b967 --- /dev/null +++ b/template/src/{{ project_slug }}/services/note_service.py.jinja @@ -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 ----------------------------------------------------------- diff --git a/template/tests/test_example_notes.py.jinja b/template/tests/test_example_notes.py.jinja new file mode 100644 index 0000000..0a34a40 --- /dev/null +++ b/template/tests/test_example_notes.py.jinja @@ -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 ----------------------------------------------------------- From b2a37bab419f25cd244c171193f62d490a936d98 Mon Sep 17 00:00:00 2001 From: Marius Helf Date: Thu, 11 Jun 2026 22:06:14 +0200 Subject: [PATCH 2/3] refactor: declare ports as ABCs instead of Protocols Switch the example NoteRepository port from typing.Protocol to an abc.ABC with @abstractmethod, and update the adapter and AGENTS.md wording accordingly: an ABC enforces the contract at instantiation time (a subclass missing an abstract method cannot be instantiated), so inheritance is mandatory rather than a convention layered on top of structural typing. Also rename AGENTS.md -> AGENTS.md.jinja so the new {{ project_slug }} references in the Architecture section actually render (copier only templates files with the .jinja suffix), and add an Architecture section to the README stating the project uses hexagonal architecture. --- template/{AGENTS.md => AGENTS.md.jinja} | 18 +++++++++--------- template/README.md.jinja | 8 ++++++++ .../in_memory_note_repository.py.jinja | 4 ++-- .../{{ project_slug }}/domain/ports.py.jinja | 19 ++++++++++--------- 4 files changed, 29 insertions(+), 20 deletions(-) rename template/{AGENTS.md => AGENTS.md.jinja} (79%) diff --git a/template/AGENTS.md b/template/AGENTS.md.jinja similarity index 79% rename from template/AGENTS.md rename to template/AGENTS.md.jinja index 76c5892..6a7e4f1 100644 --- a/template/AGENTS.md +++ b/template/AGENTS.md.jinja @@ -32,16 +32,16 @@ reverse. - 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. -- **Every class that implements a port must explicitly inherit from it** — this - applies to production adapters *and* test doubles (e.g. +- 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):`). Although Python `Protocol`s - support structural typing without inheritance, the explicit base class makes - the port↔adapter relationship discoverable in IDEs ("go to definition" jumps - to the contract) and signals intent to readers. Skipping the inheritance is - reserved for genuinely ad-hoc cases (e.g. a one-line - `unittest.mock.MagicMock(spec=NoteRepository)`, which enforces conformance via - `spec=`) and should be the exception, not the default. + `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: diff --git a/template/README.md.jinja b/template/README.md.jinja index 68a62dc..66d33f0 100644 --- a/template/README.md.jinja +++ b/template/README.md.jinja @@ -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 diff --git a/template/src/{{ project_slug }}/adapters/in_memory_note_repository.py.jinja b/template/src/{{ project_slug }}/adapters/in_memory_note_repository.py.jinja index 90b4bf1..e72f259 100644 --- a/template/src/{{ project_slug }}/adapters/in_memory_note_repository.py.jinja +++ b/template/src/{{ project_slug }}/adapters/in_memory_note_repository.py.jinja @@ -9,8 +9,8 @@ from {{ project_slug }}.domain.ports import NoteRepository class InMemoryNoteRepository(NoteRepository): """A dependency-free ``NoteRepository`` backed by a dict. - Explicitly inherits from ``NoteRepository`` so the port-adapter relationship - is discoverable; the ``Protocol`` would also match structurally without it. + Inherits from the ``NoteRepository`` ABC, which enforces that every abstract + method is implemented before the class can be instantiated. """ def __init__(self) -> None: diff --git a/template/src/{{ project_slug }}/domain/ports.py.jinja b/template/src/{{ project_slug }}/domain/ports.py.jinja index 3456d89..d0e7c26 100644 --- a/template/src/{{ project_slug }}/domain/ports.py.jinja +++ b/template/src/{{ project_slug }}/domain/ports.py.jinja @@ -1,28 +1,29 @@ """Driven ports: interfaces the application depends on, implemented by adapters. -Ports are declared as :class:`typing.Protocol` classes. Although Protocols -support structural typing, every adapter should *explicitly* inherit from the -port it implements (e.g. ``class InMemoryNoteRepository(NoteRepository): ...``). -The explicit base class makes the port-adapter relationship discoverable in -IDEs ("go to definition" jumps to the contract) and signals intent to readers. +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 typing import Protocol +from abc import ABC, abstractmethod from {{ project_slug }}.domain.models import Note -class NoteRepository(Protocol): +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 ----------------------------------------------------------- From a9ecb6fb4d8ee57a76fdc20a4363d65353a640cc Mon Sep 17 00:00:00 2001 From: Marius Helf Date: Fri, 12 Jun 2026 00:23:29 +0200 Subject: [PATCH 3/3] fix: derive a valid project_slug from any project_name The project_slug default sanitised only a blocklist of characters (spaces, hyphens, dots), so other characters leaked into the slug. The default project_name ("'s new project") contains an apostrophe, producing the invalid package name "joe_doe's_new_project" on a defaults render. Switch to allowlist sanitisation (collapse every run of non-alphanumeric characters into a single underscore, then trim) and add a validator that rejects any project_slug that is not a valid Python package name, whether derived or user-supplied. Add regression tests covering both paths. --- copier.yml | 14 +++++++++++- tests/test_template.py | 50 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/copier.yml b/copier.yml index 5b35989..c79e4de 100644 --- a/copier.yml +++ b/copier.yml @@ -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 diff --git a/tests/test_template.py b/tests/test_template.py index d6f322b..df6a777 100644 --- a/tests/test_template.py +++ b/tests/test_template.py @@ -1,5 +1,6 @@ """Integration tests for the copier template.""" +import re import subprocess from pathlib import Path @@ -7,6 +8,10 @@ import copier +# A valid Python package / import name: lowercase letters, digits and +# underscores, not starting with a digit. +VALID_PACKAGE_NAME = re.compile(r"^[a-z_][a-z0-9_]*$") + TEMPLATE_ROOT = Path(__file__).resolve().parent.parent COPIER_DATA = { @@ -186,3 +191,48 @@ def test_make_test_docs(project_path): assert result.returncode == 0, ( f"make test-docs failed:\nstdout: {result.stdout}\nstderr: {result.stderr}" ) + + +def test_default_slug_is_valid_package_name(tmp_path): + """The auto-derived project_slug must be a valid Python package name. + + The default project_name ("'s new project") contains an + apostrophe, which a blocklist of character replacements would leak into the + slug (e.g. ``joe_doe's_new_project``) — an invalid package name that breaks + ``uv`` and imports. We render with the apostrophe-bearing defaults and no + explicit project_slug, then assert the derived slug and package directory + are valid. + """ + dst = tmp_path / "generated" + copier.run_copy( + src_path=str(TEMPLATE_ROOT), + dst_path=str(dst), + data={"author_name": "Joe Doe", "author_email": "joe@example.com"}, + defaults=True, + unsafe=True, + vcs_ref="HEAD", + ) + package_dirs = [p.name for p in (dst / "src").iterdir() if p.is_dir()] + assert package_dirs, "no package directory was rendered under src/" + slug = package_dirs[0] + assert VALID_PACKAGE_NAME.match(slug), f"invalid package name: {slug!r}" + assert slug.isidentifier(), f"slug is not a valid identifier: {slug!r}" + + +def test_invalid_project_slug_is_rejected(tmp_path): + """An explicitly supplied invalid project_slug must fail validation. + + The validator guards against bad user input regardless of the default, so a + slug containing an apostrophe (or any non-identifier character) must abort + rendering rather than producing a broken project. + """ + dst = tmp_path / "generated" + with pytest.raises(ValueError, match="project_slug"): + copier.run_copy( + src_path=str(TEMPLATE_ROOT), + dst_path=str(dst), + data={**COPIER_DATA, "project_slug": "joe_doe's_project"}, + defaults=True, + unsafe=True, + vcs_ref="HEAD", + )