T-11: Validate repository Protocol structural compatibility — minimal approach (Option B)

Problem: Repository modules use structural typing to satisfy Protocol interfaces via
cast(). A function rename, parameter change, or signature mismatch would silently pass
mypy but fail at runtime.

Solution (Option B — minimal):
1. Aligned Protocol signatures in protocols.py with actual implementations:
   - BlocklistRepository: dict[str, object] → dict[str, Any] (matches implementation)
   - ImportLogRepository: dict[str, object] → ImportLogRow (typed model)
   - GeoCacheRepository: dict[str, object] → GeoCacheRow; Iterable → Sequence
   - HistoryArchiveRepository: dict[str, object] → dict[str, Any]
   - ImportLogRepository: async compute_total_pages → sync (matches implementation)

2. Created CI validation script (backend/scripts/validate_repository_protocols.py)
   that runs at build time to ensure all repository modules satisfy their Protocol
   interfaces. Exit 0 if valid, 1 if any mismatch. Detects:
   - Missing functions
   - Parameter count mismatches
   - Type annotation mismatches
   - Return type mismatches

3. Updated backend/app/dependencies.py with explicit docstrings linking each
   get_*_repo() provider to Backend-Development.md § 13.7.1, explaining the
   module-as-Protocol pattern and that it is intentional and validated.

4. Documented the pattern in Backend-Development.md § 13.7.1:
   'Repository Module Pattern — Module-as-Protocol Structural Compatibility'
   explaining why the pattern works, risks (silent breakage), and how the
   validation mitigates it.

5. Fixed type annotation in history_archive_repo.py:
   - get_all_archived_history returns list[dict] → list[dict[str, Any]]
   - Imported Any type

Benefits:
- Prevents silent breakage of repository interfaces
- Formalizes the module-as-Protocol pattern as intentional
- CI validation prevents regressions without refactoring cost
- All repository tests pass (53/53)
- mypy --strict passes on modified files

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-04-25 18:59:49 +02:00
parent 4b8af1d43a
commit b44b72053a
7 changed files with 260 additions and 45 deletions

View File

@@ -7,7 +7,7 @@ application database.
from __future__ import annotations
import datetime
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any
from app.models.ban import BLOCKLIST_JAIL, BanOrigin
@@ -54,7 +54,7 @@ async def get_archived_history(
action: str | None = None,
page: int = 1,
page_size: int = 100,
) -> tuple[list[dict], int]:
) -> tuple[list[dict[str, Any]], int]:
"""Return a paginated archived history result set."""
if isinstance(ip_filter, list) and len(ip_filter) == 0:
return [], 0
@@ -128,11 +128,11 @@ async def get_all_archived_history(
ip_filter: str | list[str] | None = None,
origin: BanOrigin | None = None,
action: str | None = None,
) -> list[dict]:
) -> list[dict[str, Any]]:
"""Return all archived history rows for the given filters."""
page: int = 1
page_size: int = 500
all_rows: list[dict] = []
all_rows: list[dict[str, Any]] = []
while True:
rows, total = await get_archived_history(

View File

@@ -6,14 +6,16 @@ module implementations, making the backend easier to test and extend.
from __future__ import annotations
from collections.abc import Iterable
from typing import Protocol
from collections.abc import Iterable, Sequence
from typing import Any, Protocol
import aiosqlite
from app.models.auth import Session
from app.models.ban import BanOrigin
from app.repositories.fail2ban_db_repo import BanIpCount, BanRecord, HistoryRecord, JailBanCount
from app.repositories.geo_cache_repo import GeoCacheRow
from app.repositories.import_log_repo import ImportLogRow
class SessionRepository(Protocol):
@@ -81,13 +83,13 @@ class BlocklistRepository(Protocol):
self,
db: aiosqlite.Connection,
source_id: int,
) -> dict[str, object] | None:
) -> dict[str, Any] | None:
...
async def list_sources(self, db: aiosqlite.Connection) -> list[dict[str, object]]:
async def list_sources(self, db: aiosqlite.Connection) -> list[dict[str, Any]]:
...
async def list_enabled_sources(self, db: aiosqlite.Connection) -> list[dict[str, object]]:
async def list_enabled_sources(self, db: aiosqlite.Connection) -> list[dict[str, Any]]:
...
async def update_source(
@@ -125,18 +127,18 @@ class ImportLogRepository(Protocol):
source_id: int | None = None,
page: int = 1,
page_size: int = 50,
) -> tuple[list[dict[str, object]], int]:
) -> tuple[list[ImportLogRow], int]:
...
async def get_last_log(self, db: aiosqlite.Connection) -> dict[str, object] | None:
async def get_last_log(self, db: aiosqlite.Connection) -> ImportLogRow | None:
...
async def compute_total_pages(self, total: int, page_size: int) -> int:
def compute_total_pages(self, total: int, page_size: int) -> int:
...
class GeoCacheRepository(Protocol):
async def load_all(self, db: aiosqlite.Connection) -> list[dict[str, object]]:
async def load_all(self, db: aiosqlite.Connection) -> list[GeoCacheRow]:
...
async def get_unresolved_ips(self, db: aiosqlite.Connection) -> list[str]:
@@ -176,14 +178,14 @@ class GeoCacheRepository(Protocol):
async def bulk_upsert_entries(
self,
db: aiosqlite.Connection,
rows: Iterable[tuple[str, str | None, str | None, str | None, str | None]],
rows: Sequence[tuple[str, str | None, str | None, str | None, str | None]],
) -> int:
...
async def bulk_upsert_entries_and_commit(
self,
db: aiosqlite.Connection,
rows: Iterable[tuple[str, str | None, str | None, str | None, str | None]],
rows: Sequence[tuple[str, str | None, str | None, str | None, str | None]],
) -> int:
...
@@ -196,7 +198,7 @@ class GeoCacheRepository(Protocol):
async def bulk_upsert_entries_and_neg_entries_and_commit(
self,
db: aiosqlite.Connection,
rows: Iterable[tuple[str, str | None, str | None, str | None, str | None]],
rows: Sequence[tuple[str, str | None, str | None, str | None, str | None]],
ips: list[str],
) -> tuple[int, int]:
...
@@ -230,7 +232,7 @@ class HistoryArchiveRepository(Protocol):
action: str | None = None,
page: int = 1,
page_size: int = 100,
) -> tuple[list[dict[str, object]], int]:
) -> tuple[list[dict[str, Any]], int]:
...