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
7 changes: 5 additions & 2 deletions .github/workflows/template-ci.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
# Template repository CI: bake the Copier template and run the generated
# project's full quality gate (Ruff, Pyrefly, pytest at 100% coverage).
# The generated project ships its own workflow at template/.github/workflows/build.yml.
# project's full quality gate (Ruff, Pyrefly, pytest at 100% coverage) across
# the feature-flag matrix. The parametrized bake-test in tests/test_bake.py
# renders the template with include_user_example both on and off, so a single
# pytest run validates every variant. The generated project ships its own
# workflow at template/.github/workflows/build.yml.

name: Template CI

Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,9 @@ uv run pytest

The bake-test ([`tests/test_bake.py`](tests/test_bake.py)) snapshots the tracked working tree, renders it with
a sample answer set, then runs `uv sync`, `ruff check`, `pyrefly check`, and `pytest --cov src` inside the
baked project, asserting 100% coverage. The same bake runs in CI via
baked project, asserting 100% coverage. It is parametrized over the `include_user_example` feature flag, so
both the default (User example on) and the monitor-only baseline (User example off) are validated on every
run. The same bake matrix runs in CI via
[`.github/workflows/template-ci.yml`](.github/workflows/template-ci.yml).

## Acknowledgements
Expand Down
21 changes: 21 additions & 0 deletions copier.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,27 @@ _exclude:
- ".ruff_cache"
- ".pytest_cache"
- ".mypy_cache"
# When the User example slice is disabled, drop its source, adapters,
# entrypoints, migration, and dedicated tests. Copier matches _exclude against
# the *rendered* destination path, so patterns use the final names (no .jinja
# suffix). Each pattern renders to an empty string when the flag is on, which
# Copier ignores.
- "{% if not include_user_example %}**/domain/commands/user.py{% endif %}"
- "{% if not include_user_example %}**/domain/events/user.py{% endif %}"
- "{% if not include_user_example %}**/domain/models/user.py{% endif %}"
- "{% if not include_user_example %}**/adapters/models/user.py{% endif %}"
- "{% if not include_user_example %}**/adapters/repository.py{% endif %}"
- "{% if not include_user_example %}**/adapters/queries.py{% endif %}"
- "{% if not include_user_example %}**/entrypoint/users.py{% endif %}"
- "{% if not include_user_example %}**/service_layer/handlers.py{% endif %}"
- "{% if not include_user_example %}**/service_layer/queries.py{% endif %}"
- "{% if not include_user_example %}**/service_layer/read_models.py{% endif %}"
- "{% if not include_user_example %}**/service_layer/repository.py{% endif %}"
- "{% if not include_user_example %}**/versions/*create_users.py{% endif %}"
- "{% if not include_user_example %}**/unit/domain/models/test_user.py{% endif %}"
- "{% if not include_user_example %}**/service_layer/test_handlers.py{% endif %}"
- "{% if not include_user_example %}**/integration/test_persistence.py{% endif %}"
- "{% if not include_user_example %}**/e2e/entrypoint/test_users.py{% endif %}"

_message_after_copy: |
Your project "{{ project_name }}" is ready.
Expand Down
2 changes: 2 additions & 0 deletions template/migrations/env.py.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ from alembic import context
from sqlalchemy import engine_from_config, pool

from {{ package_name }}.adapters.models.base import Base
{%- if include_user_example %}
from {{ package_name }}.adapters.models.user import UserRecord # noqa: F401
{%- endif %}
from {{ package_name }}.settings.database_settings import DatabaseSettings

config = context.config
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ from types import TracebackType
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session, sessionmaker

{% if include_user_example -%}
from {{ package_name }}.adapters.repository import SqlAlchemyUserRepository
{% endif -%}
from {{ package_name }}.service_layer.unit_of_work import AbstractUnitOfWork, IntegrityConflict


Expand All @@ -25,7 +27,9 @@ class SqlAlchemyUnitOfWork(AbstractUnitOfWork):
def __enter__(self) -> SqlAlchemyUnitOfWork:
"""Open a session and repositories."""
self.session = self.session_factory()
{%- if include_user_example %}
self.users = SqlAlchemyUserRepository(self.session)
{%- endif %}
return self

def __exit__(
Expand All @@ -41,7 +45,9 @@ class SqlAlchemyUnitOfWork(AbstractUnitOfWork):
def commit(self) -> None:
"""Persist tracked aggregate changes and commit the transaction."""
try:
{%- if include_user_example %}
self.users.persist_changes()
{%- endif %}
self.session.commit()
except IntegrityError as error:
raise IntegrityConflict from error
Expand Down
24 changes: 22 additions & 2 deletions template/src/{{ package_name }}/bootstrap.py.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,22 @@ from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool

from {{ package_name }}.adapters.models.base import Base
{%- if include_user_example %}
from {{ package_name }}.adapters.queries import SqlAlchemyUserReader
{%- endif %}
from {{ package_name }}.adapters.unit_of_work import SqlAlchemyUnitOfWork
{%- if include_user_example %}
from {{ package_name }}.domain.commands.user import DeactivateUser, RegisterUser
from {{ package_name }}.domain.events.user import UserDeactivated, UserRegistered
from {{ package_name }}.service_layer import handlers
{%- endif %}
from {{ package_name }}.service_layer.messagebus import MessageBus
{%- if include_user_example %}
from {{ package_name }}.service_layer.queries import UserReader
{%- endif %}
from {{ package_name }}.service_layer.unit_of_work import AbstractUnitOfWork
from {{ package_name }}.settings.database_settings import DatabaseSettings
{%- if include_user_example %}


def _ignore_user_registered(event: UserRegistered) -> None:
Expand All @@ -29,6 +36,7 @@ def _ignore_user_registered(event: UserRegistered) -> None:

def _ignore_user_deactivated(event: UserDeactivated) -> None:
"""Provide a default no-op external event publisher."""
{%- endif %}


@dataclass
Expand All @@ -38,9 +46,11 @@ class ApplicationContainer:
engine: Engine
session_factory: sessionmaker[Session]
uow_factory: Callable[[], AbstractUnitOfWork]
user_reader: UserReader
bus: MessageBus
auto_create_schema: bool
{%- if include_user_example %}
user_reader: UserReader
{%- endif %}

def startup(self) -> None:
"""Initialize resources required by the running application."""
Expand All @@ -54,13 +64,17 @@ class ApplicationContainer:

def bootstrap(
database_settings: DatabaseSettings | None = None,
{%- if include_user_example %}
publish: Callable[[UserRegistered], None] = _ignore_user_registered,
{%- endif %}
) -> ApplicationContainer:
"""Build application dependencies.

Args:
database_settings: Optional database configuration override.
{%- if include_user_example %}
publish: External user-registration event publisher.
{%- endif %}

Returns:
A configured application container.
Expand All @@ -76,6 +90,7 @@ def bootstrap(
engine = create_engine(settings.URL, **engine_options)
session_factory = sessionmaker(bind=engine, expire_on_commit=False)
uow_factory = partial(SqlAlchemyUnitOfWork, session_factory)
{%- if include_user_example %}
user_reader = SqlAlchemyUserReader(session_factory)
bus = MessageBus(
uow_factory=uow_factory,
Expand All @@ -88,11 +103,16 @@ def bootstrap(
UserDeactivated: [partial(handlers.publish_user_deactivated, publish=_ignore_user_deactivated)],
},
)
{%- else %}
bus = MessageBus(uow_factory=uow_factory, command_handlers={}, event_handlers={})
{%- endif %}
return ApplicationContainer(
engine=engine,
session_factory=session_factory,
uow_factory=uow_factory,
user_reader=user_reader,
bus=bus,
auto_create_schema=settings.AUTO_CREATE_SCHEMA,
{%- if include_user_example %}
user_reader=user_reader,
{%- endif %}
)
6 changes: 6 additions & 0 deletions template/src/{{ package_name }}/router.py.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@ Resources:

from fastapi import APIRouter

{% if include_user_example -%}
from {{ package_name }}.entrypoint import monitor, users
{%- else -%}
from {{ package_name }}.entrypoint import monitor
{%- endif %}

api_v1_prefix: str = "/api/v1"

Expand All @@ -17,6 +21,8 @@ api_router_v1: APIRouter = APIRouter(prefix=api_v1_prefix)

# Base routers
root_router.include_router(monitor.router)
{%- if include_user_example %}

# API routers
api_router_v1.include_router(users.router)
{%- endif %}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ from collections.abc import Iterator
from types import TracebackType

from {{ package_name }}.domain.messages import Event
{%- if include_user_example %}
from {{ package_name }}.service_layer.repository import UserRepository
{%- endif %}


class IntegrityConflict(RuntimeError):
Expand All @@ -16,8 +18,10 @@ class IntegrityConflict(RuntimeError):

class AbstractUnitOfWork(ABC):
"""Provide atomic persistence and event collection."""
{%- if include_user_example %}

users: UserRepository
{%- endif %}

def __enter__(self) -> AbstractUnitOfWork:
"""Enter the transaction boundary."""
Expand All @@ -42,6 +46,7 @@ class AbstractUnitOfWork(ABC):

def collect_new_events(self) -> Iterator[Event]:
"""Yield pending events from aggregates seen in this transaction.
{%- if include_user_example %}

A unit of work that was never entered has no repository; in that case
no aggregate was seen and the iterator yields nothing.
Expand All @@ -52,3 +57,11 @@ class AbstractUnitOfWork(ABC):
for user in users.seen.values():
while user.events:
yield user.events.pop(0)
{%- else %}

Without the example aggregate slice this monitor-only baseline tracks no
aggregates, so the iterator yields nothing.
"""
return
yield # pragma: no cover - unreachable generator sentinel
{%- endif %}
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,11 @@ def test_upgrades_a_blank_database_to_head(self, tmp_path: Path):
"""
GIVEN a blank SQLite database
WHEN Alembic upgrades the database to head
{%- if include_user_example %}
THEN the user schema and Alembic revision table exist
{%- else %}
THEN Alembic records its revision table even with no migrations yet
{%- endif %}
"""
# GIVEN
database_path = tmp_path / "migration-test.db"
Expand All @@ -28,4 +32,6 @@ def test_upgrades_a_blank_database_to_head(self, tmp_path: Path):
# THEN
tables = inspect(create_engine(database_url)).get_table_names()
assert "alembic_version" in tables
{%- if include_user_example %}
assert "users" in tables
{%- endif %}
118 changes: 118 additions & 0 deletions template/tests/integration/test_unit_of_work.py.jinja
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
"""Integration tests for the SQLAlchemy unit of work.

These tests exercise the transaction adapter directly, independent of any
domain aggregate slice, so the monitor-only baseline keeps the unit of work
covered. A throwaway SQLAlchemy record defined against the shared declarative
base stands in for a persisted row without depending on example domain models.
"""

from collections.abc import Iterator

import pytest
from sqlalchemy import String, create_engine, select
from sqlalchemy.orm import Mapped, Session, mapped_column, sessionmaker
from sqlalchemy.pool import StaticPool

from {{ package_name }}.adapters.models.base import Base
from {{ package_name }}.adapters.unit_of_work import SqlAlchemyUnitOfWork
from {{ package_name }}.service_layer.unit_of_work import AbstractUnitOfWork, IntegrityConflict


class _Widget(Base):
"""A throwaway record used to drive the unit of work in tests."""

__tablename__ = "uow_test_widgets"

id: Mapped[str] = mapped_column(String(), primary_key=True)


@pytest.fixture(name="session_factory")
def fixture_session_factory() -> Iterator[sessionmaker[Session]]:
"""Create an isolated in-memory SQLAlchemy session factory."""
engine = create_engine("sqlite+pysqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
Base.metadata.create_all(engine)
yield sessionmaker(bind=engine, expire_on_commit=False)
engine.dispose()


class TestSqlAlchemyUnitOfWork:
"""Test transaction semantics without a domain aggregate slice."""

def test_commits_a_row(self, session_factory: sessionmaker[Session]):
"""
GIVEN a SQLAlchemy unit of work
WHEN a row is added and explicitly committed
THEN a later session observes the persisted row
"""
# WHEN
with SqlAlchemyUnitOfWork(session_factory) as uow:
uow.session.add(_Widget(id="alpha"))
uow.commit()

# THEN
with session_factory() as session:
assert session.get(_Widget, "alpha") is not None

def test_rolls_back_uncommitted_work(self, session_factory: sessionmaker[Session]):
"""
GIVEN a SQLAlchemy unit of work
WHEN a row is added without an explicit commit
THEN the implicit rollback on exit discards the row
"""
# WHEN
with SqlAlchemyUnitOfWork(session_factory) as uow:
uow.session.add(_Widget(id="beta"))

# THEN
with session_factory() as session:
assert session.scalar(select(_Widget).where(_Widget.id == "beta")) is None

def test_translates_integrity_errors(self, session_factory: sessionmaker[Session]):
"""
GIVEN a row that already exists
WHEN a conflicting primary key is committed
THEN the adapter raises an application persistence conflict
"""
# GIVEN
with SqlAlchemyUnitOfWork(session_factory) as uow:
uow.session.add(_Widget(id="gamma"))
uow.commit()

# WHEN / THEN
with pytest.raises(IntegrityConflict), SqlAlchemyUnitOfWork(session_factory) as uow:
uow.session.add(_Widget(id="gamma"))
uow.commit()


class TestAbstractUnitOfWork:
"""Test the default transaction-boundary behavior."""

def test_enter_returns_self_and_exit_rolls_back(self):
"""
GIVEN a unit of work using the default boundary behavior
WHEN it is used as a context manager without committing
THEN entering returns the instance and exiting rolls work back
"""

# GIVEN
class _RecordingUnitOfWork(AbstractUnitOfWork):
"""A unit of work that records its commit and rollback calls."""

def __init__(self) -> None:
self.rolled_back = False

def commit(self) -> None:
"""Do nothing."""

def rollback(self) -> None:
"""Record a rollback."""
self.rolled_back = True

uow = _RecordingUnitOfWork()

# WHEN
with uow as entered:
assert entered is uow

# THEN
assert uow.rolled_back is True
Loading
Loading