From f6ee9a37242c8bb00f48dcbc129b311eabaffccb Mon Sep 17 00:00:00 2001 From: Brian Bjarke Jensen Date: Sat, 11 Jul 2026 09:45:42 +0200 Subject: [PATCH] Add runtime-checkable Protocol typing to all public interfaces. Enables structural subtyping for consumers while preserving nominal adapter inheritance, instantiation guards, and scan_keys defaults. Co-authored-by: Cursor --- README.md | 2 + python_repositories/adapters/minio_adapter.py | 2 +- python_repositories/adapters/redis_adapter.py | 2 +- .../interfaces/connection_aware_interface.py | 8 +- .../interfaces/context_aware_interface.py | 9 +- .../interfaces/json_repository_interface.py | 9 +- .../interfaces/object_repository_interface.py | 8 +- tests/unit/structural_typing_test.py | 134 ++++++++++++++++++ 8 files changed, 158 insertions(+), 16 deletions(-) create mode 100644 tests/unit/structural_typing_test.py diff --git a/README.md b/README.md index 29a8a95..1939d29 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,8 @@ Subclass an adapter in your own repository to add domain-specific methods while | **Adapters** | Technology-specific base classes (`RedisAdapter`, `MinioAdapter`) | | **Your project** | Subclass an adapter and add domain methods | +Each public interface is a `@runtime_checkable` `Protocol` with `@abstractmethod` members. **Subclass an adapter** when you need connection management and shared behavior — explicit subclasses get runtime instantiation guards and inherited default methods (e.g. `scan_keys`). **Type-annotate against an interface** when you want loose coupling — any object with the right methods satisfies the contract for mypy and `isinstance()` checks, without inheriting from this package. + Connection adapters expose `connect()`, `disconnect()`, and `is_connected()`. The latter verifies backend reachability with a cached health probe (default TTL: 1 second). Subclasses may override `health_check_ttl_seconds`. `connect()` is idempotent: calling it while already connected and healthy is a no-op. ## Future direction diff --git a/python_repositories/adapters/minio_adapter.py b/python_repositories/adapters/minio_adapter.py index 3e71d9f..db4751c 100644 --- a/python_repositories/adapters/minio_adapter.py +++ b/python_repositories/adapters/minio_adapter.py @@ -19,7 +19,7 @@ except ImportError as exc: ) from exc -class MinioAdapter(ObjectRepositoryInterface, ConnectionAwareAdapter): +class MinioAdapter(ConnectionAwareAdapter, ObjectRepositoryInterface): """Minio adapter exposing basic CRUD functionality.""" endpoint_env_var_name: str = "MINIO_ENDPOINT" diff --git a/python_repositories/adapters/redis_adapter.py b/python_repositories/adapters/redis_adapter.py index 88bdbfa..5a21c5a 100644 --- a/python_repositories/adapters/redis_adapter.py +++ b/python_repositories/adapters/redis_adapter.py @@ -21,7 +21,7 @@ except ImportError as exc: ) from exc -class RedisAdapter(JsonRepositoryInterface, ConnectionAwareAdapter): +class RedisAdapter(ConnectionAwareAdapter, JsonRepositoryInterface): """Redis adapter exposing basic CRUD functionality.""" uri_env_var_name: str = "REDIS_URI" diff --git a/python_repositories/interfaces/connection_aware_interface.py b/python_repositories/interfaces/connection_aware_interface.py index 0cba6a4..e1a6d3a 100644 --- a/python_repositories/interfaces/connection_aware_interface.py +++ b/python_repositories/interfaces/connection_aware_interface.py @@ -1,9 +1,11 @@ -"""Definition of ConnectionAwareInterface abstract base class.""" +"""Definition of ConnectionAwareInterface protocol and abstract base class.""" -from abc import ABC, abstractmethod +from abc import abstractmethod +from typing import Protocol, runtime_checkable -class ConnectionAwareInterface(ABC): +@runtime_checkable +class ConnectionAwareInterface(Protocol): """Interface that defines connection-related methods.""" @abstractmethod diff --git a/python_repositories/interfaces/context_aware_interface.py b/python_repositories/interfaces/context_aware_interface.py index 7815304..9f29eae 100644 --- a/python_repositories/interfaces/context_aware_interface.py +++ b/python_repositories/interfaces/context_aware_interface.py @@ -1,12 +1,13 @@ -"""Definition of ContextAwareInterface abstract base class.""" +"""Definition of ContextAwareInterface protocol and abstract base class.""" from __future__ import annotations -from abc import ABC, abstractmethod -from typing import Self +from abc import abstractmethod +from typing import Protocol, Self, runtime_checkable -class ContextAwareInterface(ABC): +@runtime_checkable +class ContextAwareInterface(Protocol): """Interface that defines context-related methods.""" @abstractmethod diff --git a/python_repositories/interfaces/json_repository_interface.py b/python_repositories/interfaces/json_repository_interface.py index 66ae079..2a13e7a 100644 --- a/python_repositories/interfaces/json_repository_interface.py +++ b/python_repositories/interfaces/json_repository_interface.py @@ -1,11 +1,12 @@ -"""Definition of JsonRepositoryInterface abstract base class.""" +"""Definition of JsonRepositoryInterface protocol and abstract base class.""" -from abc import ABC, abstractmethod +from abc import abstractmethod from collections.abc import Iterator -from typing import Any +from typing import Any, Protocol, runtime_checkable -class JsonRepositoryInterface(ABC): +@runtime_checkable +class JsonRepositoryInterface(Protocol): """Interface that defines JSON document CRUD methods.""" @abstractmethod diff --git a/python_repositories/interfaces/object_repository_interface.py b/python_repositories/interfaces/object_repository_interface.py index 85482f9..7f07a9f 100644 --- a/python_repositories/interfaces/object_repository_interface.py +++ b/python_repositories/interfaces/object_repository_interface.py @@ -1,10 +1,12 @@ -"""Definition of ObjectRepositoryInterface abstract base class.""" +"""Definition of ObjectRepositoryInterface protocol and abstract base class.""" -from abc import ABC, abstractmethod +from abc import abstractmethod from io import BytesIO +from typing import Protocol, runtime_checkable -class ObjectRepositoryInterface(ABC): +@runtime_checkable +class ObjectRepositoryInterface(Protocol): """Interface that defines binary object CRUD methods.""" @abstractmethod diff --git a/tests/unit/structural_typing_test.py b/tests/unit/structural_typing_test.py new file mode 100644 index 0000000..ddbc5a6 --- /dev/null +++ b/tests/unit/structural_typing_test.py @@ -0,0 +1,134 @@ +"""Unit tests for structural typing of public interfaces.""" + +from __future__ import annotations + +from collections.abc import Iterator +from io import BytesIO +from typing import Any + +from python_repositories.interfaces import ( + ConnectionAwareInterface, + ContextAwareInterface, + JsonRepositoryInterface, + ObjectRepositoryInterface, +) + + +class InMemoryJsonRepo: + """Plain class that satisfies JsonRepositoryInterface without inheritance.""" + + def get(self, key: str) -> dict[str, Any] | None: + return None + + def set(self, key: str, data: dict[str, Any]) -> None: + pass + + def delete(self, key: str) -> None: + pass + + def list_keys(self, pattern: str) -> list[str]: + return [] + + def scan_keys( + self, + pattern: str, + *, + count: int | None = None, + ) -> Iterator[str]: + del count + yield from self.list_keys(pattern) + + +class InMemoryObjectRepo: + """Plain class that satisfies ObjectRepositoryInterface without inheritance.""" + + def get(self, object_name: str) -> BytesIO | None: + return None + + def put( + self, + object_name: str, + data: BytesIO, + content_type: str = "application/octet-stream", + ) -> None: + pass + + def delete(self, object_name: str) -> None: + pass + + def list_objects(self, prefix: str = "") -> list[str]: + return [] + + +class FakeConnection: + """Plain class that satisfies ConnectionAwareInterface without inheritance.""" + + def connect(self) -> None: + pass + + def disconnect(self) -> None: + pass + + def is_connected(self) -> bool: + return True + + +class FakeContextManager: + """Plain class that satisfies ContextAwareInterface without inheritance.""" + + def __enter__(self) -> FakeContextManager: + return self + + def __exit__( + self, exc_type: type | None, exc_val: object | None, exc_tb: object | None + ) -> None: + pass + + +def accepts_json_repo(repo: JsonRepositoryInterface) -> None: + """Type-checking hook for JsonRepositoryInterface structural subtyping.""" + repo.get("key") + + +def accepts_object_repo(repo: ObjectRepositoryInterface) -> None: + """Type-checking hook for ObjectRepositoryInterface structural subtyping.""" + repo.get("object") + + +def accepts_connection_aware(connection: ConnectionAwareInterface) -> None: + """Type-checking hook for ConnectionAwareInterface structural subtyping.""" + connection.is_connected() + + +def accepts_context_aware(context: ContextAwareInterface) -> None: + """Type-checking hook for ContextAwareInterface structural subtyping.""" + with context: + pass + + +def test_json_repository_structural_subtyping() -> None: + """Test that a plain class satisfies JsonRepositoryInterface structurally.""" + repo: JsonRepositoryInterface = InMemoryJsonRepo() + accepts_json_repo(repo) + assert isinstance(repo, JsonRepositoryInterface) + + +def test_object_repository_structural_subtyping() -> None: + """Test that a plain class satisfies ObjectRepositoryInterface structurally.""" + repo: ObjectRepositoryInterface = InMemoryObjectRepo() + accepts_object_repo(repo) + assert isinstance(repo, ObjectRepositoryInterface) + + +def test_connection_aware_structural_subtyping() -> None: + """Test that a plain class satisfies ConnectionAwareInterface structurally.""" + connection: ConnectionAwareInterface = FakeConnection() + accepts_connection_aware(connection) + assert isinstance(connection, ConnectionAwareInterface) + + +def test_context_aware_structural_subtyping() -> None: + """Test that a plain class satisfies ContextAwareInterface structurally.""" + context: ContextAwareInterface = FakeContextManager() + accepts_context_aware(context) + assert isinstance(context, ContextAwareInterface)