diff --git a/CHANGELOG.md b/CHANGELOG.md index 14c49b55..e9853671 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,33 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). --- +## v26.06.16 (2026-06-05) + +### Fixed + +- **The `hexagonal` archetype now actually wires its ports.** The generated app + defined inbound (use-case) and outbound (repository) ports that **nothing + implemented** — `resolve(TodoRepositoryPort)` raised `NoSuchBeanError`, the + ports were dead code, and `TodoService`'s docstring "Implements the inbound + ports" was false (the DI scanner binds ports by MRO, so an adapter must inherit + the port). Now the application service implements the four inbound use-case + ports and the in-memory adapter implements the outbound repository port, so both + resolve and the architecture is genuinely hexagonal. The use-case boundary is + async across all variants (in-memory / relational / document) for consistency. +- **Generated hexagonal code is `mypy --strict`-clean and handles not-found.** The + data-relational / data-document variants dereferenced `find_by_id()`'s + `T | None` result without a check (a `mypy` error and a latent `AttributeError` + on a missing id); they now raise `ResourceNotFoundException` (→ 404). +- **DTO id type fixed.** `TodoResponseDTO.id` was `int` in the relational variant + while the domain `Todo.id` is always `str`; it is now `str` in every variant. + +These surfaced in an audit while validating the `implement-hexagonal-adapter` skill +(which validated clean — DI resolves a Protocol/ABC outbound port to its adapter, +the keystone capability for the pattern; zero/multiple-implementation cases raise +clear `NoSuchBeanError` / `NoUniqueBeanError`). + +--- + ## v26.06.15 (2026-06-05) ### Fixed diff --git a/README.md b/README.md index bda8d443..e18c48a6 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Firefly Framework Python 3.12+ License: Apache 2.0 - Version: 26.06.15 + Version: 26.06.16 Type Checked: mypy strict Code Style: Ruff Async First diff --git a/pyproject.toml b/pyproject.toml index 91c87359..053c7428 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ name = "pyfly" # CalVer YY.MM.PATCH — package metadata uses PEP 440 normalized form (26.5.4); # git tag, GitHub release and human-readable display use leading-zero form # (v26.05.04) to match the Java/.NET/Go siblings. -version = "26.6.15" +version = "26.6.16" description = "The official Python implementation of the Firefly Framework — DI, CQRS, EDA, hexagonal architecture, and more." readme = "README.md" license = "Apache-2.0" diff --git a/src/pyfly/__init__.py b/src/pyfly/__init__.py index 6a96548e..08514259 100644 --- a/src/pyfly/__init__.py +++ b/src/pyfly/__init__.py @@ -13,4 +13,4 @@ # limitations under the License. """PyFly — Enterprise Python Framework.""" -__version__ = "26.06.15" +__version__ = "26.06.16" diff --git a/src/pyfly/cli/templates/hex/app_services.py.j2 b/src/pyfly/cli/templates/hex/app_services.py.j2 index 8eb8dbe1..e88fae42 100644 --- a/src/pyfly/cli/templates/hex/app_services.py.j2 +++ b/src/pyfly/cli/templates/hex/app_services.py.j2 @@ -1,10 +1,17 @@ -"""Application services — use-case implementations.""" +"""Application services — use-case (inbound port) implementations.""" from __future__ import annotations from pyfly.container import service +from pyfly.kernel.exceptions import ResourceNotFoundException from {{ package_name }}.domain.models import Todo +from {{ package_name }}.domain.ports.inbound import ( + CreateTodoUseCase, + DeleteTodoUseCase, + GetTodoUseCase, + ListTodosUseCase, +) {% if has_data %} from {{ package_name }}.domain.models import TodoEntity from {{ package_name }}.infrastructure.adapters.persistence import TodoRepository @@ -12,87 +19,107 @@ from {{ package_name }}.infrastructure.adapters.persistence import TodoRepositor from {{ package_name }}.domain.models import TodoDocument from {{ package_name }}.infrastructure.adapters.persistence import TodoDocumentRepository {% else %} -from {{ package_name }}.infrastructure.adapters.persistence import InMemoryTodoRepository +from {{ package_name }}.domain.ports.outbound import TodoRepositoryPort {% endif %} @service -class TodoService: - """Implements the inbound ports for todo management.""" +class TodoService(CreateTodoUseCase, GetTodoUseCase, ListTodosUseCase, DeleteTodoUseCase): + """Implements the inbound use-case ports for todo management.""" {% if has_data %} def __init__(self, repository: TodoRepository) -> None: self._repository = repository - async def create(self, title: str, description: str) -> Todo: - entity = TodoEntity(title=title, description=description) - saved = await self._repository.save(entity) - return Todo(id=str(saved.id), title=saved.title, description=saved.description, completed=saved.completed) + @staticmethod + def _entity_id(todo_id: str) -> int: + try: + return int(todo_id) + except ValueError as exc: + raise ResourceNotFoundException(f"Todo '{todo_id}' not found", code="TODO_NOT_FOUND") from exc - async def get(self, todo_id: int) -> Todo: - entity = await self._repository.find_by_id(todo_id) + @staticmethod + def _to_domain(entity: TodoEntity) -> Todo: return Todo(id=str(entity.id), title=entity.title, description=entity.description, completed=entity.completed) + async def create(self, title: str, description: str) -> Todo: + saved = await self._repository.save(TodoEntity(title=title, description=description)) + return self._to_domain(saved) + + async def get(self, todo_id: str) -> Todo: + entity = await self._repository.find_by_id(self._entity_id(todo_id)) + if entity is None: + raise ResourceNotFoundException(f"Todo '{todo_id}' not found", code="TODO_NOT_FOUND") + return self._to_domain(entity) + async def list_all(self) -> list[Todo]: - entities = await self._repository.find_all() - return [Todo(id=str(e.id), title=e.title, description=e.description, completed=e.completed) for e in entities] + return [self._to_domain(e) for e in await self._repository.find_all()] - async def toggle_complete(self, todo_id: int) -> Todo: - entity = await self._repository.find_by_id(todo_id) + async def toggle_complete(self, todo_id: str) -> Todo: + entity = await self._repository.find_by_id(self._entity_id(todo_id)) + if entity is None: + raise ResourceNotFoundException(f"Todo '{todo_id}' not found", code="TODO_NOT_FOUND") entity.completed = not entity.completed - saved = await self._repository.save(entity) - return Todo(id=str(saved.id), title=saved.title, description=saved.description, completed=saved.completed) + return self._to_domain(await self._repository.save(entity)) - async def delete(self, todo_id: int) -> None: - await self._repository.delete(todo_id) + async def delete(self, todo_id: str) -> None: + await self._repository.delete(self._entity_id(todo_id)) {% elif has_mongodb %} def __init__(self, repository: TodoDocumentRepository) -> None: self._repository = repository + @staticmethod + def _to_domain(doc: TodoDocument) -> Todo: + return Todo(id=str(doc.id), title=doc.title, description=doc.description, completed=doc.completed) + async def create(self, title: str, description: str) -> Todo: - doc = TodoDocument(title=title, description=description) - saved = await self._repository.save(doc) - return Todo(id=str(saved.id), title=saved.title, description=saved.description, completed=saved.completed) + saved = await self._repository.save(TodoDocument(title=title, description=description)) + return self._to_domain(saved) async def get(self, todo_id: str) -> Todo: doc = await self._repository.find_by_id(todo_id) - return Todo(id=str(doc.id), title=doc.title, description=doc.description, completed=doc.completed) + if doc is None: + raise ResourceNotFoundException(f"Todo '{todo_id}' not found", code="TODO_NOT_FOUND") + return self._to_domain(doc) async def list_all(self) -> list[Todo]: - docs = await self._repository.find_all() - return [Todo(id=str(d.id), title=d.title, description=d.description, completed=d.completed) for d in docs] + return [self._to_domain(d) for d in await self._repository.find_all()] async def toggle_complete(self, todo_id: str) -> Todo: doc = await self._repository.find_by_id(todo_id) + if doc is None: + raise ResourceNotFoundException(f"Todo '{todo_id}' not found", code="TODO_NOT_FOUND") doc.completed = not doc.completed - saved = await self._repository.save(doc) - return Todo(id=str(saved.id), title=saved.title, description=saved.description, completed=saved.completed) + return self._to_domain(await self._repository.save(doc)) async def delete(self, todo_id: str) -> None: await self._repository.delete(todo_id) {% else %} - def __init__(self, repository: InMemoryTodoRepository) -> None: + def __init__(self, repository: TodoRepositoryPort) -> None: self._repository = repository - def create(self, title: str, description: str) -> Todo: + async def create(self, title: str, description: str) -> Todo: todo = Todo(title=title, description=description) - self._repository.save(todo) + await self._repository.save(todo) return todo - def get(self, todo_id: str) -> Todo: - return self._repository.find_by_id(todo_id) + async def get(self, todo_id: str) -> Todo: + todo = await self._repository.find_by_id(todo_id) + if todo is None: + raise ResourceNotFoundException(f"Todo '{todo_id}' not found", code="TODO_NOT_FOUND") + return todo - def list_all(self) -> list[Todo]: - return self._repository.find_all() + async def list_all(self) -> list[Todo]: + return await self._repository.find_all() - def toggle_complete(self, todo_id: str) -> Todo: - todo = self._repository.find_by_id(todo_id) + async def toggle_complete(self, todo_id: str) -> Todo: + todo = await self.get(todo_id) todo.completed = not todo.completed - self._repository.save(todo) + await self._repository.save(todo) return todo - def delete(self, todo_id: str) -> None: - self._repository.delete(todo_id) + async def delete(self, todo_id: str) -> None: + await self._repository.delete(todo_id) {% endif %} diff --git a/src/pyfly/cli/templates/hex/controllers.py.j2 b/src/pyfly/cli/templates/hex/controllers.py.j2 index 9710a5bc..e7f625ee 100644 --- a/src/pyfly/cli/templates/hex/controllers.py.j2 +++ b/src/pyfly/cli/templates/hex/controllers.py.j2 @@ -1,4 +1,4 @@ -"""REST controllers — API layer.""" +"""REST controllers — inbound (driving) adapter for the API layer.""" from pyfly.container import rest_controller from pyfly.web import PathVar, Valid, delete_mapping, get_mapping, post_mapping, put_mapping, request_mapping @@ -25,44 +25,24 @@ class TodoController: @get_mapping("/") async def list_todos(self) -> list[TodoResponseDTO]: -{% if has_data or has_mongodb %} todos = await self._service.list_all() -{% else %} - todos = self._service.list_all() -{% endif %} return [TodoResponseDTO(id=t.id, title=t.title, description=t.description, completed=t.completed) for t in todos] @get_mapping("/{todo_id}") - async def get_todo(self, todo_id: PathVar[{% if has_data %}int{% else %}str{% endif %}]) -> TodoResponseDTO: -{% if has_data or has_mongodb %} + async def get_todo(self, todo_id: PathVar[str]) -> TodoResponseDTO: todo = await self._service.get(todo_id) -{% else %} - todo = self._service.get(todo_id) -{% endif %} return TodoResponseDTO(id=todo.id, title=todo.title, description=todo.description, completed=todo.completed) @post_mapping("/", status_code=201) async def create_todo(self, body: Valid[TodoCreateRequest]) -> TodoResponseDTO: -{% if has_data or has_mongodb %} todo = await self._service.create(title=body.title, description=body.description) -{% else %} - todo = self._service.create(title=body.title, description=body.description) -{% endif %} return TodoResponseDTO(id=todo.id, title=todo.title, description=todo.description, completed=todo.completed) @put_mapping("/{todo_id}") - async def update_todo(self, todo_id: PathVar[{% if has_data %}int{% else %}str{% endif %}]) -> TodoResponseDTO: -{% if has_data or has_mongodb %} + async def update_todo(self, todo_id: PathVar[str]) -> TodoResponseDTO: todo = await self._service.toggle_complete(todo_id) -{% else %} - todo = self._service.toggle_complete(todo_id) -{% endif %} return TodoResponseDTO(id=todo.id, title=todo.title, description=todo.description, completed=todo.completed) @delete_mapping("/{todo_id}", status_code=204) - async def delete_todo(self, todo_id: PathVar[{% if has_data %}int{% else %}str{% endif %}]) -> None: -{% if has_data or has_mongodb %} + async def delete_todo(self, todo_id: PathVar[str]) -> None: await self._service.delete(todo_id) -{% else %} - self._service.delete(todo_id) -{% endif %} diff --git a/src/pyfly/cli/templates/hex/dto.py.j2 b/src/pyfly/cli/templates/hex/dto.py.j2 index e51cce14..bc8bb102 100644 --- a/src/pyfly/cli/templates/hex/dto.py.j2 +++ b/src/pyfly/cli/templates/hex/dto.py.j2 @@ -9,8 +9,7 @@ class TodoCreateRequest(BaseModel): class TodoResponseDTO(BaseModel): - id: {% if has_data %}int{% else %}str{% endif %} - + id: str title: str description: str = "" completed: bool = False diff --git a/src/pyfly/cli/templates/hex/persistence.py.j2 b/src/pyfly/cli/templates/hex/persistence.py.j2 index b8c607ed..b710c819 100644 --- a/src/pyfly/cli/templates/hex/persistence.py.j2 +++ b/src/pyfly/cli/templates/hex/persistence.py.j2 @@ -29,31 +29,32 @@ class TodoDocumentRepository(MongoRepository[TodoDocument, str]): """Document repository — inherits full CRUD. Add custom queries by defining stub methods.""" {% endif %} {% else %} -"""Persistence adapter — implements outbound repository port.""" +"""Persistence adapter — implements the outbound repository port.""" from __future__ import annotations from pyfly.container import repository from {{ package_name }}.domain.models import Todo +from {{ package_name }}.domain.ports.outbound import TodoRepositoryPort @repository -class InMemoryTodoRepository: - """In-memory implementation. Replace with a database-backed adapter.""" +class InMemoryTodoRepository(TodoRepositoryPort): + """In-memory adapter implementing TodoRepositoryPort. Replace with a database-backed adapter.""" def __init__(self) -> None: self._store: dict[str, Todo] = {} - def save(self, todo: Todo) -> None: + async def save(self, todo: Todo) -> None: self._store[todo.id] = todo - def find_by_id(self, todo_id: str) -> Todo: - return self._store[todo_id] + async def find_by_id(self, todo_id: str) -> Todo | None: + return self._store.get(todo_id) - def find_all(self) -> list[Todo]: + async def find_all(self) -> list[Todo]: return list(self._store.values()) - def delete(self, todo_id: str) -> None: + async def delete(self, todo_id: str) -> None: self._store.pop(todo_id, None) {% endif %} diff --git a/src/pyfly/cli/templates/hex/ports_inbound.py.j2 b/src/pyfly/cli/templates/hex/ports_inbound.py.j2 index 5f9fde53..316690e6 100644 --- a/src/pyfly/cli/templates/hex/ports_inbound.py.j2 +++ b/src/pyfly/cli/templates/hex/ports_inbound.py.j2 @@ -1,4 +1,4 @@ -"""Inbound ports — use-case interfaces.""" +"""Inbound ports — use-case interfaces (driven side).""" from __future__ import annotations @@ -8,16 +8,16 @@ from {{ package_name }}.domain.models import Todo class CreateTodoUseCase(Protocol): - def create(self, title: str, description: str) -> Todo: ... + async def create(self, title: str, description: str) -> Todo: ... class GetTodoUseCase(Protocol): - def get(self, todo_id: str) -> Todo: ... + async def get(self, todo_id: str) -> Todo: ... class ListTodosUseCase(Protocol): - def list_all(self) -> list[Todo]: ... + async def list_all(self) -> list[Todo]: ... class DeleteTodoUseCase(Protocol): - def delete(self, todo_id: str) -> None: ... + async def delete(self, todo_id: str) -> None: ... diff --git a/src/pyfly/cli/templates/hex/ports_outbound.py.j2 b/src/pyfly/cli/templates/hex/ports_outbound.py.j2 index 3daa74dc..ca621c28 100644 --- a/src/pyfly/cli/templates/hex/ports_outbound.py.j2 +++ b/src/pyfly/cli/templates/hex/ports_outbound.py.j2 @@ -1,4 +1,4 @@ -"""Outbound ports — repository / infrastructure interfaces.""" +"""Outbound ports — repository / infrastructure interfaces (driving side).""" from __future__ import annotations @@ -8,7 +8,7 @@ from {{ package_name }}.domain.models import Todo class TodoRepositoryPort(Protocol): - def save(self, todo: Todo) -> None: ... - def find_by_id(self, todo_id: str) -> Todo: ... - def find_all(self) -> list[Todo]: ... - def delete(self, todo_id: str) -> None: ... + async def save(self, todo: Todo) -> None: ... + async def find_by_id(self, todo_id: str) -> Todo | None: ... + async def find_all(self) -> list[Todo]: ... + async def delete(self, todo_id: str) -> None: ... diff --git a/src/pyfly/cli/templates/hex/test_services.py.j2 b/src/pyfly/cli/templates/hex/test_services.py.j2 index 51ebfd55..e5992fc8 100644 --- a/src/pyfly/cli/templates/hex/test_services.py.j2 +++ b/src/pyfly/cli/templates/hex/test_services.py.j2 @@ -9,28 +9,28 @@ class TestTodoService: self.repository = InMemoryTodoRepository() self.service = TodoService(self.repository) - def test_create_todo(self) -> None: - todo = self.service.create("Buy groceries", "Milk and eggs") + async def test_create_todo(self) -> None: + todo = await self.service.create("Buy groceries", "Milk and eggs") assert todo.title == "Buy groceries" assert todo.id - def test_list_todos(self) -> None: - self.service.create("Task A", "") - self.service.create("Task B", "") - assert len(self.service.list_all()) == 2 + async def test_list_todos(self) -> None: + await self.service.create("Task A", "") + await self.service.create("Task B", "") + assert len(await self.service.list_all()) == 2 - def test_get_todo(self) -> None: - created = self.service.create("Buy groceries", "") - found = self.service.get(created.id) + async def test_get_todo(self) -> None: + created = await self.service.create("Buy groceries", "") + found = await self.service.get(created.id) assert found.title == "Buy groceries" - def test_toggle_complete(self) -> None: - created = self.service.create("Buy groceries", "") + async def test_toggle_complete(self) -> None: + created = await self.service.create("Buy groceries", "") assert created.completed is False - toggled = self.service.toggle_complete(created.id) + toggled = await self.service.toggle_complete(created.id) assert toggled.completed is True - def test_delete_todo(self) -> None: - created = self.service.create("Buy groceries", "") - self.service.delete(created.id) - assert len(self.service.list_all()) == 0 + async def test_delete_todo(self) -> None: + created = await self.service.create("Buy groceries", "") + await self.service.delete(created.id) + assert len(await self.service.list_all()) == 0 diff --git a/tests/cli/test_scaffold_hexagonal_ports.py b/tests/cli/test_scaffold_hexagonal_ports.py new file mode 100644 index 00000000..92351230 --- /dev/null +++ b/tests/cli/test_scaffold_hexagonal_ports.py @@ -0,0 +1,56 @@ +# Copyright 2026 Firefly Software Foundation. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Regression: the generated hexagonal archetype actually WIRES its ports (v26.06.16). + +The archetype used to define inbound/outbound ports that nothing inherited, so they +were dead code — ``resolve(TodoRepositoryPort)`` raised ``NoSuchBeanError`` and the +service docstring "Implements the inbound ports" was false. The application service +now implements the inbound use-case ports and the in-memory adapter implements the +outbound repository port (so both resolve via the DI scanner's MRO binding). +""" + +from __future__ import annotations + +import ast +from pathlib import Path + +from pyfly.cli.templates import generate_project + + +def _src(project_dir: Path, package: str, rel: str) -> str: + text = (project_dir / "src" / package / rel).read_text(encoding="utf-8") + ast.parse(text) # generated file must be valid Python + return text + + +class TestHexagonalArchetypeWiring: + def test_ports_are_wired(self, tmp_path: Path) -> None: + proj = tmp_path / "hex" + generate_project(name="todo", project_dir=proj, archetype="hexagonal", features=["web"], package_name="todo") + + services = _src(proj, "todo", "application/services.py") + # The service implements ALL four inbound use-case ports. + assert "class TodoService(CreateTodoUseCase, GetTodoUseCase, ListTodosUseCase, DeleteTodoUseCase):" in services + assert "from todo.domain.ports.inbound import" in services + assert "async def create" in services # async use-case boundary + + # The in-memory adapter implements the outbound repository port. + persistence = _src(proj, "todo", "infrastructure/adapters/persistence.py") + assert "class InMemoryTodoRepository(TodoRepositoryPort):" in persistence + assert "from todo.domain.ports.outbound import TodoRepositoryPort" in persistence + + # The response DTO uses the domain id type (str), not the entity int. + dto = _src(proj, "todo", "api/dto.py") + assert "id: str" in dto + assert "id: int" not in dto diff --git a/uv.lock b/uv.lock index 9f39851e..71f374c1 100644 --- a/uv.lock +++ b/uv.lock @@ -1967,7 +1967,7 @@ wheels = [ [[package]] name = "pyfly" -version = "26.6.15" +version = "26.6.16" source = { editable = "." } dependencies = [ { name = "pydantic" },