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:
@@ -211,49 +211,86 @@ async def get_session_cache(app_context: Annotated[ApplicationContext, Depends(g
|
||||
|
||||
|
||||
async def get_session_repo() -> SessionRepository:
|
||||
"""Provide the concrete session repository implementation."""
|
||||
"""Provide the concrete session repository implementation.
|
||||
|
||||
The session_repo module uses structural typing to satisfy the SessionRepository
|
||||
Protocol interface — its top-level async functions must match the Protocol
|
||||
signatures exactly. This is documented in Backend-Development.md § 13.7.1.
|
||||
"""
|
||||
from app.repositories import session_repo # noqa: PLC0415
|
||||
|
||||
return session_repo
|
||||
|
||||
|
||||
async def get_blocklist_repo() -> BlocklistRepository:
|
||||
"""Provide the concrete blocklist repository implementation."""
|
||||
"""Provide the concrete blocklist repository implementation.
|
||||
|
||||
The blocklist_repo module uses structural typing to satisfy the BlocklistRepository
|
||||
Protocol interface — its top-level async functions must match the Protocol
|
||||
signatures exactly. This is documented in Backend-Development.md § 13.7.1.
|
||||
"""
|
||||
from app.repositories import blocklist_repo # noqa: PLC0415
|
||||
|
||||
return cast("BlocklistRepository", blocklist_repo)
|
||||
|
||||
|
||||
async def get_import_log_repo() -> ImportLogRepository:
|
||||
"""Provide the concrete import log repository implementation."""
|
||||
"""Provide the concrete import log repository implementation.
|
||||
|
||||
The import_log_repo module uses structural typing to satisfy the ImportLogRepository
|
||||
Protocol interface — its top-level async functions must match the Protocol
|
||||
signatures exactly. This is documented in Backend-Development.md § 13.7.1.
|
||||
"""
|
||||
from app.repositories import import_log_repo # noqa: PLC0415
|
||||
|
||||
return cast("ImportLogRepository", import_log_repo)
|
||||
|
||||
|
||||
async def get_settings_repo() -> SettingsRepository:
|
||||
"""Provide the concrete settings repository implementation."""
|
||||
"""Provide the concrete settings repository implementation.
|
||||
|
||||
The settings_repo module uses structural typing to satisfy the SettingsRepository
|
||||
Protocol interface — its top-level async functions must match the Protocol
|
||||
signatures exactly. This is documented in Backend-Development.md § 13.7.1.
|
||||
"""
|
||||
from app.repositories import settings_repo # noqa: PLC0415
|
||||
|
||||
return cast("SettingsRepository", settings_repo)
|
||||
|
||||
|
||||
async def get_history_archive_repo() -> HistoryArchiveRepository:
|
||||
"""Provide the concrete history archive repository implementation."""
|
||||
"""Provide the concrete history archive repository implementation.
|
||||
|
||||
The history_archive_repo module uses structural typing to satisfy the
|
||||
HistoryArchiveRepository Protocol interface — its top-level async functions
|
||||
must match the Protocol signatures exactly. This is documented in
|
||||
Backend-Development.md § 13.7.1.
|
||||
"""
|
||||
from app.repositories import history_archive_repo # noqa: PLC0415
|
||||
|
||||
return cast("HistoryArchiveRepository", history_archive_repo)
|
||||
|
||||
|
||||
async def get_geo_cache_repo() -> GeoCacheRepository:
|
||||
"""Provide the concrete geo cache repository implementation."""
|
||||
"""Provide the concrete geo cache repository implementation.
|
||||
|
||||
The geo_cache_repo module uses structural typing to satisfy the GeoCacheRepository
|
||||
Protocol interface — its top-level async functions must match the Protocol
|
||||
signatures exactly. This is documented in Backend-Development.md § 13.7.1.
|
||||
"""
|
||||
from app.repositories import geo_cache_repo # noqa: PLC0415
|
||||
|
||||
return cast("GeoCacheRepository", geo_cache_repo)
|
||||
|
||||
|
||||
async def get_fail2ban_db_repo() -> Fail2BanDbRepository:
|
||||
"""Provide the concrete fail2ban DB repository implementation."""
|
||||
"""Provide the concrete fail2ban DB repository implementation.
|
||||
|
||||
The fail2ban_db_repo module uses structural typing to satisfy the
|
||||
Fail2BanDbRepository Protocol interface — its top-level async functions must
|
||||
match the Protocol signatures exactly. This is documented in
|
||||
Backend-Development.md § 13.7.1.
|
||||
"""
|
||||
from app.repositories import fail2ban_db_repo # noqa: PLC0415
|
||||
|
||||
return cast("Fail2BanDbRepository", fail2ban_db_repo)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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]:
|
||||
...
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user