From e98fd3b90dcc9d9e89e637f376c845e1662ae925 Mon Sep 17 00:00:00 2001 From: Brian Bjarke Jensen Date: Sun, 28 Jun 2026 10:58:33 +0200 Subject: [PATCH 1/3] Add public repository interfaces and subclassable adapter CRUD API. Define split JSON and object repository ABCs, promote adapter methods to public CRUD, add example domain repositories, modernize interface stubs, and make the package installable for tests. Co-authored-by: Cursor --- README.md | 105 ++++++++++++++++-- pyproject.toml | 8 ++ python_repositories/__init__.py | 18 ++- python_repositories/adapters/minio_adapter.py | 12 +- python_repositories/adapters/redis_adapter.py | 12 +- python_repositories/examples/__init__.py | 1 + .../examples/artifact_object_repository.py | 23 ++++ .../examples/user_json_repository.py | 19 ++++ python_repositories/interfaces/__init__.py | 8 ++ .../interfaces/connection_aware_interface.py | 6 +- .../interfaces/context_aware_interface.py | 8 +- .../interfaces/json_repository_interface.py | 27 +++++ .../interfaces/object_repository_interface.py | 33 ++++++ .../connection_aware_interface_test.py | 63 ----------- .../context_aware_interface_test.py | 40 +------ tests/integration/examples_test.py | 57 ++++++++++ .../json_repository_interface_test.py | 82 ++++++++++++++ tests/integration/minio_adapter_test.py | 62 +++++------ .../object_repository_interface_test.py | 99 +++++++++++++++++ tests/integration/redis_adapter_test.py | 52 +++++---- uv.lock | 2 +- 21 files changed, 547 insertions(+), 190 deletions(-) create mode 100644 python_repositories/examples/__init__.py create mode 100644 python_repositories/examples/artifact_object_repository.py create mode 100644 python_repositories/examples/user_json_repository.py create mode 100644 python_repositories/interfaces/json_repository_interface.py create mode 100644 python_repositories/interfaces/object_repository_interface.py create mode 100644 tests/integration/examples_test.py create mode 100644 tests/integration/json_repository_interface_test.py create mode 100644 tests/integration/object_repository_interface_test.py diff --git a/README.md b/README.md index 10a8f3c..7440821 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,105 @@ # python-repositories -Various python repository interfaces exposed as a python package. +Unified repository interfaces and technology-specific adapters for Python projects. + +Subclass an adapter in your own repository to add domain-specific methods while reusing connection management and CRUD operations. + +## Architecture + +| Layer | Responsibility | +|-------|----------------| +| **Interfaces** | Abstract contracts for connection, context, and CRUD | +| **Adapters** | Technology-specific base classes (`RedisAdapter`, `MinioAdapter`) | +| **Your project** | Subclass an adapter and add domain methods | ## Optional dependencies -This package supports interacting with multiple different backends: - -- Redis -- MinIO - -To add support for a specific backend install this package with one or more of these optional packages: +Install with the extras you need: ```bash -uv add python-repositories[redis, minio] +uv add python-repositories[redis] +uv add python-repositories[minio] +uv add python-repositories[redis,minio] ``` + +### Redis (`JsonRepositoryInterface`) + +Requires Redis with the RedisJSON module (e.g. redis-stack). + +| Environment variable | Description | +|---------------------|-------------| +| `REDIS_URI` | Redis connection URL (e.g. `redis://localhost:6379`) | + +### MinIO (`ObjectRepositoryInterface`) + +| Environment variable | Description | +|---------------------|-------------| +| `MINIO_ENDPOINT` | MinIO server endpoint | +| `MINIO_ACCESS_KEY` | Access key | +| `MINIO_SECRET_KEY` | Secret key | +| `MINIO_BUCKET` | Bucket name (created on connect if missing) | + +## Quick start + +### JSON documents with Redis + +```python +from python_repositories.examples.user_json_repository import UserJsonRepository + +with UserJsonRepository() as repo: + repo.save_user("alice", {"name": "Alice", "email": "alice@example.com"}) + user = repo.get_user("alice") + repo.delete_user("alice") +``` + +### Binary objects with MinIO + +```python +from io import BytesIO + +from python_repositories.examples.artifact_object_repository import ( + ArtifactObjectRepository, +) + +with ArtifactObjectRepository() as repo: + repo.store_artifact("report-1", BytesIO(b"pdf bytes here")) + data = repo.get_artifact("report-1") +``` + +### Subclassing in your own project + +```python +from python_repositories import RedisAdapter + +class UserRepository(RedisAdapter): + def _key(self, user_id: str) -> str: + return f"user:{user_id}" + + def get_user(self, user_id: str) -> dict | None: + return self.get(self._key(user_id)) + + def save_user(self, user_id: str, user: dict) -> None: + self.set(self._key(user_id), user) +``` + +## Public API + +```python +from python_repositories import ( + ConnectionAwareInterface, + ContextAwareInterface, + JsonRepositoryInterface, + ObjectRepositoryInterface, + RedisAdapter, + MinioAdapter, +) +``` + +## Development + +```bash +uv sync --all-extras +uv run pytest tests/integration/ -v +``` + +Integration tests require Docker (testcontainers). diff --git a/pyproject.toml b/pyproject.toml index 6f87f89..6f1a724 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,14 @@ minio = [ "minio>=7.2.16", ] +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["."] + [tool.uv.sources] python-utils = { index = "gitea" } diff --git a/python_repositories/__init__.py b/python_repositories/__init__.py index 670152d..bb5154d 100644 --- a/python_repositories/__init__.py +++ b/python_repositories/__init__.py @@ -1,6 +1,18 @@ """python_repositories: Unified repository interfaces and adapters.""" -from . import adapters -from . import interfaces +from .adapters import MinioAdapter, RedisAdapter +from .interfaces import ( + ConnectionAwareInterface, + ContextAwareInterface, + JsonRepositoryInterface, + ObjectRepositoryInterface, +) -__all__ = ["adapters", "interfaces"] +__all__ = [ + "ConnectionAwareInterface", + "ContextAwareInterface", + "JsonRepositoryInterface", + "ObjectRepositoryInterface", + "RedisAdapter", + "MinioAdapter", +] diff --git a/python_repositories/adapters/minio_adapter.py b/python_repositories/adapters/minio_adapter.py index d391a95..e0053e1 100644 --- a/python_repositories/adapters/minio_adapter.py +++ b/python_repositories/adapters/minio_adapter.py @@ -8,8 +8,9 @@ import structlog from python_utils import check_env from python_repositories.interfaces import ( - ContextAwareInterface, ConnectionAwareInterface, + ContextAwareInterface, + ObjectRepositoryInterface, ) # Handle optional dependencies @@ -18,6 +19,7 @@ if find_spec("minio") is not None: class MinioAdapter( + ObjectRepositoryInterface, ContextAwareInterface, ConnectionAwareInterface, ): @@ -113,7 +115,7 @@ class MinioAdapter( self.logger.debug(res) return res - def _put( + def put( self, object_name: str, data: BytesIO, @@ -147,7 +149,7 @@ class MinioAdapter( f"Put object '{object_name}' into bucket '{self._bucket_name}'" ) - def _get(self, object_name: str) -> BytesIO | None: + def get(self, object_name: str) -> BytesIO | None: """Get an object from the Minio bucket.""" # Check input if not isinstance(object_name, str) or len(object_name) == 0: @@ -182,7 +184,7 @@ class MinioAdapter( self.logger.error(repr(exc)) return None - def _delete(self, object_name: str) -> None: + def delete(self, object_name: str) -> None: """Delete an object from the Minio bucket.""" # Check input if not isinstance(object_name, str) or len(object_name) == 0: @@ -200,7 +202,7 @@ class MinioAdapter( f"Deleted object '{object_name}' from bucket '{self._bucket_name}'" ) - def _list_objects(self, prefix: str = "") -> list[str]: + def list_objects(self, prefix: str = "") -> list[str]: """List objects in the Minio bucket with an optional prefix.""" # Check input if not isinstance(prefix, str): diff --git a/python_repositories/adapters/redis_adapter.py b/python_repositories/adapters/redis_adapter.py index 8b3ed8b..5e0c455 100644 --- a/python_repositories/adapters/redis_adapter.py +++ b/python_repositories/adapters/redis_adapter.py @@ -9,8 +9,9 @@ import structlog from python_utils import check_env from python_repositories.interfaces import ( - ContextAwareInterface, ConnectionAwareInterface, + ContextAwareInterface, + JsonRepositoryInterface, ) # Handle optional dependencies @@ -20,6 +21,7 @@ if find_spec("redis") is not None: class RedisAdapter( + JsonRepositoryInterface, ContextAwareInterface, ConnectionAwareInterface, ): @@ -92,7 +94,7 @@ class RedisAdapter( self.logger.debug(res) return res - def _set(self, key: str, data: dict) -> None: + def set(self, key: str, data: dict) -> None: """Set a JSON object in Redis.""" # Check input if not isinstance(key, str) or len(key) == 0: @@ -106,7 +108,7 @@ class RedisAdapter( self._client.json().set(key, self.path, data) self.logger.debug(f"Set {key} to {data}") - def _get(self, key: str) -> dict | None: + def get(self, key: str) -> dict | None: """Get a JSON object from Redis.""" # Check input if not isinstance(key, str) or len(key) == 0: @@ -122,7 +124,7 @@ class RedisAdapter( self.logger.debug(f"Got {data} from {key}") return data - def _delete(self, key: str) -> None: + def delete(self, key: str) -> None: """Delete data from Redis.""" # Check input if not isinstance(key, str) or len(key) == 0: @@ -134,7 +136,7 @@ class RedisAdapter( self._client.json().delete(key) self.logger.debug(f"Deleted {key}") - def _list_keys(self, pattern: str) -> list[str]: + def list_keys(self, pattern: str) -> list[str]: """List keys in Redis matching a pattern.""" # Check input if not isinstance(pattern, str) or len(pattern) == 0: diff --git a/python_repositories/examples/__init__.py b/python_repositories/examples/__init__.py new file mode 100644 index 0000000..a219e6f --- /dev/null +++ b/python_repositories/examples/__init__.py @@ -0,0 +1 @@ +"""Example domain repositories built on technology adapters.""" diff --git a/python_repositories/examples/artifact_object_repository.py b/python_repositories/examples/artifact_object_repository.py new file mode 100644 index 0000000..420f5bb --- /dev/null +++ b/python_repositories/examples/artifact_object_repository.py @@ -0,0 +1,23 @@ +"""Example domain repository backed by MinIO objects.""" + +from io import BytesIO + +from python_repositories.adapters.minio_adapter import MinioAdapter + + +class ArtifactObjectRepository(MinioAdapter): + """Example: domain repository backed by MinIO objects.""" + + def _object_name(self, artifact_id: str) -> str: + return f"artifacts/{artifact_id}" + + def get_artifact(self, artifact_id: str) -> BytesIO | None: + return self.get(self._object_name(artifact_id)) + + def store_artifact( + self, + artifact_id: str, + data: BytesIO, + content_type: str = "application/octet-stream", + ) -> None: + self.put(self._object_name(artifact_id), data, content_type) diff --git a/python_repositories/examples/user_json_repository.py b/python_repositories/examples/user_json_repository.py new file mode 100644 index 0000000..9f9962f --- /dev/null +++ b/python_repositories/examples/user_json_repository.py @@ -0,0 +1,19 @@ +"""Example domain repository backed by Redis JSON.""" + +from python_repositories.adapters.redis_adapter import RedisAdapter + + +class UserJsonRepository(RedisAdapter): + """Example: domain repository backed by Redis JSON.""" + + def _key(self, user_id: str) -> str: + return f"user:{user_id}" + + def get_user(self, user_id: str) -> dict | None: + return self.get(self._key(user_id)) + + def save_user(self, user_id: str, user: dict) -> None: + self.set(self._key(user_id), user) + + def delete_user(self, user_id: str) -> None: + self.delete(self._key(user_id)) diff --git a/python_repositories/interfaces/__init__.py b/python_repositories/interfaces/__init__.py index 9d47a8d..c643fa6 100644 --- a/python_repositories/interfaces/__init__.py +++ b/python_repositories/interfaces/__init__.py @@ -2,8 +2,16 @@ from .connection_aware_interface import ( ConnectionAwareInterface as ConnectionAwareInterface, ) from .context_aware_interface import ContextAwareInterface as ContextAwareInterface +from .json_repository_interface import ( + JsonRepositoryInterface as JsonRepositoryInterface, +) +from .object_repository_interface import ( + ObjectRepositoryInterface as ObjectRepositoryInterface, +) __all__ = [ "ConnectionAwareInterface", "ContextAwareInterface", + "JsonRepositoryInterface", + "ObjectRepositoryInterface", ] diff --git a/python_repositories/interfaces/connection_aware_interface.py b/python_repositories/interfaces/connection_aware_interface.py index 58ea48c..a1d1cbb 100644 --- a/python_repositories/interfaces/connection_aware_interface.py +++ b/python_repositories/interfaces/connection_aware_interface.py @@ -9,15 +9,15 @@ class ConnectionAwareInterface(ABC): @abstractmethod def connect(self) -> None: """Connect to resource.""" - raise NotImplementedError() + ... @abstractmethod def disconnect(self) -> None: """Disconnect from resource.""" - raise NotImplementedError() + ... @property @abstractmethod def is_connected(self) -> bool: """Check if connected to resource.""" - raise NotImplementedError() + ... diff --git a/python_repositories/interfaces/context_aware_interface.py b/python_repositories/interfaces/context_aware_interface.py index 63829be..eaf5f71 100644 --- a/python_repositories/interfaces/context_aware_interface.py +++ b/python_repositories/interfaces/context_aware_interface.py @@ -1,4 +1,4 @@ -"""Definition of ConnectionAwareInterface abstract base class.""" +"""Definition of ContextAwareInterface abstract base class.""" from __future__ import annotations @@ -6,16 +6,16 @@ from abc import ABC, abstractmethod class ContextAwareInterface(ABC): - """Interface that defined context-related methods.""" + """Interface that defines context-related methods.""" @abstractmethod def __enter__(self) -> ContextAwareInterface: """Enter the context.""" - raise NotImplementedError() + ... @abstractmethod def __exit__( self, exc_type: type | None, exc_val: object | None, exc_tb: object | None ) -> None: """Exit the context.""" - raise NotImplementedError() + ... diff --git a/python_repositories/interfaces/json_repository_interface.py b/python_repositories/interfaces/json_repository_interface.py new file mode 100644 index 0000000..1e271e4 --- /dev/null +++ b/python_repositories/interfaces/json_repository_interface.py @@ -0,0 +1,27 @@ +"""Definition of JsonRepositoryInterface abstract base class.""" + +from abc import ABC, abstractmethod + + +class JsonRepositoryInterface(ABC): + """Interface that defines JSON document CRUD methods.""" + + @abstractmethod + def get(self, key: str) -> dict | None: + """Get a JSON object by key.""" + ... + + @abstractmethod + def set(self, key: str, data: dict) -> None: + """Set a JSON object by key.""" + ... + + @abstractmethod + def delete(self, key: str) -> None: + """Delete a JSON object by key.""" + ... + + @abstractmethod + def list_keys(self, pattern: str) -> list[str]: + """List keys matching a glob pattern.""" + ... diff --git a/python_repositories/interfaces/object_repository_interface.py b/python_repositories/interfaces/object_repository_interface.py new file mode 100644 index 0000000..3fd58c0 --- /dev/null +++ b/python_repositories/interfaces/object_repository_interface.py @@ -0,0 +1,33 @@ +"""Definition of ObjectRepositoryInterface abstract base class.""" + +from abc import ABC, abstractmethod +from io import BytesIO + + +class ObjectRepositoryInterface(ABC): + """Interface that defines binary object CRUD methods.""" + + @abstractmethod + def get(self, object_name: str) -> BytesIO | None: + """Get an object by name.""" + ... + + @abstractmethod + def put( + self, + object_name: str, + data: BytesIO, + content_type: str = "application/octet-stream", + ) -> None: + """Put an object by name.""" + ... + + @abstractmethod + def delete(self, object_name: str) -> None: + """Delete an object by name.""" + ... + + @abstractmethod + def list_objects(self, prefix: str = "") -> list[str]: + """List object names with an optional prefix.""" + ... diff --git a/tests/integration/connection_aware_interface_test.py b/tests/integration/connection_aware_interface_test.py index 23e9d62..d7d2e72 100644 --- a/tests/integration/connection_aware_interface_test.py +++ b/tests/integration/connection_aware_interface_test.py @@ -54,66 +54,3 @@ def test_instantiation_fails_when_is_connected_not_implemented() -> None: with pytest.raises(TypeError): _ = Incomplete() # type: ignore - - -def test_connect_raises_not_implemented_if_not_overwritten() -> None: - """Test that connect raises NotImplementedError if not implemented.""" - - class Incomplete(ConnectionAwareInterface): - """A class that does not implement connect.""" - - def connect(self) -> None: - super().connect() # type: ignore - - def disconnect(self) -> None: - pass - - @property - def is_connected(self) -> bool: - return False - - instance = Incomplete() - with pytest.raises(NotImplementedError): - instance.connect() - - -def test_disconnect_raises_not_implemented_if_not_overwritten() -> None: - """Test that disconnect raises NotImplementedError if not implemented.""" - - class Incomplete(ConnectionAwareInterface): - """A class that does not implement disconnect.""" - - def connect(self) -> None: - pass - - def disconnect(self) -> None: - super().disconnect() # type: ignore - - @property - def is_connected(self) -> bool: - return False - - instance = Incomplete() - with pytest.raises(NotImplementedError): - instance.disconnect() - - -def test_is_connected_raises_not_implemented_if_not_overwritten() -> None: - """Test that is_connected raises NotImplementedError if not implemented.""" - - class Incomplete(ConnectionAwareInterface): - """A class that does not implement is_connected.""" - - def connect(self) -> None: - pass - - def disconnect(self) -> None: - pass - - @property - def is_connected(self) -> bool: - return super().is_connected # type: ignore - - instance = Incomplete() - with pytest.raises(NotImplementedError): - _ = instance.is_connected diff --git a/tests/integration/context_aware_interface_test.py b/tests/integration/context_aware_interface_test.py index ca6aa48..63cf953 100644 --- a/tests/integration/context_aware_interface_test.py +++ b/tests/integration/context_aware_interface_test.py @@ -1,6 +1,7 @@ """Integration tests for ContextAwareInterface.""" from __future__ import annotations + import pytest from python_repositories.interfaces.context_aware_interface import ContextAwareInterface @@ -31,42 +32,3 @@ def test_instantiation_fails_when_exit_not_implemented() -> None: with pytest.raises(TypeError): _ = Incomplete() # type: ignore - - -def test_enter_raises_not_implemented_if_not_overwritten() -> None: - """Test that __enter__ raises NotImplementedError if not implemented.""" - - class Incomplete(ContextAwareInterface): - """A class that does not implement __enter__.""" - - def __enter__(self) -> Incomplete: - super().__enter__() # type: ignore - return self - - def __exit__( - self, exc_type: type | None, exc_val: object | None, exc_tb: object | None - ) -> None: - pass - - instance = Incomplete() - with pytest.raises(NotImplementedError): - instance.__enter__() - - -def test_exit_raises_not_implemented_if_not_overwritten() -> None: - """Test that __exit__ raises NotImplementedError if not implemented.""" - - class Incomplete(ContextAwareInterface): - """A class that does not implement __exit__.""" - - def __enter__(self) -> ContextAwareInterface: - return self - - def __exit__( - self, exc_type: type | None, exc_val: object | None, exc_tb: object | None - ) -> None: - super().__exit__(exc_type, exc_val, exc_tb) # type: ignore - - instance = Incomplete() - with pytest.raises(NotImplementedError): - instance.__exit__(None, None, None) diff --git a/tests/integration/examples_test.py b/tests/integration/examples_test.py new file mode 100644 index 0000000..4586bcd --- /dev/null +++ b/tests/integration/examples_test.py @@ -0,0 +1,57 @@ +"""Integration tests for example domain repositories.""" + +from collections.abc import Generator +from io import BytesIO +import random + +import pytest +from python_repositories.examples.artifact_object_repository import ( + ArtifactObjectRepository, +) +from python_repositories.examples.user_json_repository import UserJsonRepository + + +@pytest.fixture(scope="module") +def user_data() -> Generator[dict[str, str]]: + """Provide sample user data for tests.""" + yield {"name": "Alice", "email": "alice@example.com"} + + +@pytest.fixture(scope="module") +def artifact_data() -> Generator[BytesIO]: + """Provide sample artifact data for tests.""" + yield BytesIO(random.randbytes(2**20)) + + +def test_user_json_repository_save_and_get( + redis_container: str, + user_data: dict[str, str], +) -> None: + """Test that UserJsonRepository can save and retrieve a user.""" + with UserJsonRepository() as repo: + repo.save_user("alice", user_data) + assert repo.get_user("alice") == user_data + + +def test_user_json_repository_delete( + redis_container: str, + user_data: dict[str, str], +) -> None: + """Test that UserJsonRepository can delete a user.""" + with UserJsonRepository() as repo: + repo.save_user("alice", user_data) + repo.delete_user("alice") + assert repo.get_user("alice") is None + + +def test_artifact_object_repository_store_and_get( + minio_container: dict[str, str], + artifact_data: BytesIO, +) -> None: + """Test that ArtifactObjectRepository can store and retrieve an artifact.""" + with ArtifactObjectRepository() as repo: + repo.store_artifact("report-1", artifact_data) + received = repo.get_artifact("report-1") + assert received is not None + artifact_data.seek(0) + assert received.read() == artifact_data.read() diff --git a/tests/integration/json_repository_interface_test.py b/tests/integration/json_repository_interface_test.py new file mode 100644 index 0000000..0b8c458 --- /dev/null +++ b/tests/integration/json_repository_interface_test.py @@ -0,0 +1,82 @@ +"""Integration tests for JsonRepositoryInterface.""" + +import pytest +from python_repositories.interfaces.json_repository_interface import ( + JsonRepositoryInterface, +) + + +def test_instantiation_fails_when_get_not_implemented() -> None: + """Test that instantiation fails if get is not implemented.""" + + class Incomplete(JsonRepositoryInterface): + """A class that does not implement get.""" + + def set(self, key: str, data: dict) -> None: + pass + + def delete(self, key: str) -> None: + pass + + def list_keys(self, pattern: str) -> list[str]: + return [] + + with pytest.raises(TypeError): + _ = Incomplete() # type: ignore + + +def test_instantiation_fails_when_set_not_implemented() -> None: + """Test that instantiation fails if set is not implemented.""" + + class Incomplete(JsonRepositoryInterface): + """A class that does not implement set.""" + + def get(self, key: str) -> dict | None: + return None + + def delete(self, key: str) -> None: + pass + + def list_keys(self, pattern: str) -> list[str]: + return [] + + with pytest.raises(TypeError): + _ = Incomplete() # type: ignore + + +def test_instantiation_fails_when_delete_not_implemented() -> None: + """Test that instantiation fails if delete is not implemented.""" + + class Incomplete(JsonRepositoryInterface): + """A class that does not implement delete.""" + + def get(self, key: str) -> dict | None: + return None + + def set(self, key: str, data: dict) -> None: + pass + + def list_keys(self, pattern: str) -> list[str]: + return [] + + with pytest.raises(TypeError): + _ = Incomplete() # type: ignore + + +def test_instantiation_fails_when_list_keys_not_implemented() -> None: + """Test that instantiation fails if list_keys is not implemented.""" + + class Incomplete(JsonRepositoryInterface): + """A class that does not implement list_keys.""" + + def get(self, key: str) -> dict | None: + return None + + def set(self, key: str, data: dict) -> None: + pass + + def delete(self, key: str) -> None: + pass + + with pytest.raises(TypeError): + _ = Incomplete() # type: ignore diff --git a/tests/integration/minio_adapter_test.py b/tests/integration/minio_adapter_test.py index 95ad882..fa66a47 100644 --- a/tests/integration/minio_adapter_test.py +++ b/tests/integration/minio_adapter_test.py @@ -1,6 +1,3 @@ -# pylint: disable=protected-access -# The above line disables pylint's protected member access warnings for this file, -# allowing tests to access MinioAdapter's internal methods as needed for integration testing. """Integration tests for the MinioAdapter.""" from collections.abc import Generator @@ -13,6 +10,7 @@ import os import logging from minio import S3Error from python_repositories.adapters.minio_adapter import MinioAdapter +from python_repositories.interfaces import ObjectRepositoryInterface def same_data( @@ -103,7 +101,7 @@ def clear_minio( def test_should_adhere_to_interface() -> None: """Test that the MinioAdapter adheres to the expected interface.""" - # Instantiation fails if interface not adhered to + assert issubclass(MinioAdapter, ObjectRepositoryInterface) _ = MinioAdapter() @@ -187,7 +185,7 @@ def test_should_get_data( # Arrange object_name, expected_data = data_in_minio # Act - received_data = minio_adapter._get(object_name) + received_data = minio_adapter.get(object_name) # Assert assert received_data is not None assert same_data(expected_data, received_data) @@ -200,7 +198,7 @@ def test_should_get_none_for_nonexistent_object( # Arrange object_name = "nonexistent_object" # Act - received_data = minio_adapter._get(object_name) + received_data = minio_adapter.get(object_name) # Assert assert received_data is None @@ -214,7 +212,7 @@ def test_should_raise_value_error_on_invalid_get_object_name( # Act & Assert for object_name in invalid_object_names: with pytest.raises(ValueError): - minio_adapter._get(object_name) # type: ignore + minio_adapter.get(object_name) # type: ignore def test_should_raise_connection_error_on_get_when_not_connected( @@ -225,7 +223,7 @@ def test_should_raise_connection_error_on_get_when_not_connected( adapter = MinioAdapter() # not connected # Act & Assert with pytest.raises(ConnectionError): - adapter._get("some_object") + adapter.get("some_object") def test_should_log_warning_when_getting_nonexistent_object( @@ -249,7 +247,7 @@ def test_should_log_warning_when_getting_nonexistent_object( object_name = "missing-object" # Act with caplog.at_level("WARNING"): - result = adapter._get(object_name) + result = adapter.get(object_name) # Assert assert result is None assert ( @@ -280,7 +278,7 @@ def test_should_log_error_when_getting_with_s3error_other_than_no_such_key( object_name = "missing-object" # Act with caplog.at_level("ERROR"): - result = adapter._get(object_name) + result = adapter.get(object_name) # Assert assert result is None assert repr(other_s3error) in caplog.text @@ -299,7 +297,7 @@ def test_should_log_error_when_getting_with_general_exception( object_name = "missing-object" # Act with caplog.at_level("ERROR"): - result = adapter._get(object_name) + result = adapter.get(object_name) # Assert assert result is None assert repr(general_exception) in caplog.text @@ -312,12 +310,12 @@ def test_should_put_data( """Test that the MinioAdapter can put data into a bucket.""" # Arrange object_name = "new_test_object" - received_data = minio_adapter._get(object_name) + received_data = minio_adapter.get(object_name) assert received_data is None # ensure object does not exist yet # Act - minio_adapter._put(object_name, data) + minio_adapter.put(object_name, data) # Assert - received_data = minio_adapter._get(object_name) + received_data = minio_adapter.get(object_name) assert received_data is not None assert same_data(data, received_data) # Cleanup @@ -333,13 +331,13 @@ def test_should_update_data( # Arrange object_name, _ = data_in_minio new_data = BytesIO(random.randbytes(2**21)) # 2 MiB - received_data = minio_adapter._get(object_name) + received_data = minio_adapter.get(object_name) assert received_data is not None assert not same_data(received_data, new_data) # Act - minio_adapter._put(object_name, new_data) + minio_adapter.put(object_name, new_data) # Assert - received_data = minio_adapter._get(object_name) + received_data = minio_adapter.get(object_name) assert received_data is not None assert same_data(new_data, received_data) @@ -354,7 +352,7 @@ def test_should_raise_value_error_on_invalid_put_object_name( # Act & Assert for object_name in invalid_object_names: with pytest.raises(ValueError): - minio_adapter._put(object_name, data) # type: ignore + minio_adapter.put(object_name, data) # type: ignore def test_should_raise_value_error_on_invalid_put_data( @@ -367,7 +365,7 @@ def test_should_raise_value_error_on_invalid_put_data( # Act & Assert for data in invalid_data: with pytest.raises(ValueError): - minio_adapter._put(object_name, data) # type: ignore + minio_adapter.put(object_name, data) # type: ignore def test_should_raise_value_error_on_invalid_put_content_type( @@ -381,7 +379,7 @@ def test_should_raise_value_error_on_invalid_put_content_type( # Act & Assert for content_type in invalid_content_types: with pytest.raises(ValueError): - minio_adapter._put(object_name, data, content_type) # type: ignore + minio_adapter.put(object_name, data, content_type) # type: ignore def test_should_raise_connection_error_on_put_when_not_connected( @@ -393,7 +391,7 @@ def test_should_raise_connection_error_on_put_when_not_connected( adapter = MinioAdapter() # not connected # Act & Assert with pytest.raises(ConnectionError): - adapter._put("some_object", data) + adapter.put("some_object", data) def test_should_delete_object( @@ -403,12 +401,12 @@ def test_should_delete_object( """Test that the MinioAdapter can delete an object from a bucket.""" # Arrange object_name, _ = data_in_minio - received_data = minio_adapter._get(object_name) + received_data = minio_adapter.get(object_name) assert received_data is not None # ensure object exists # Act - minio_adapter._delete(object_name) + minio_adapter.delete(object_name) # Assert - received_data = minio_adapter._get(object_name) + received_data = minio_adapter.get(object_name) assert received_data is None @@ -421,7 +419,7 @@ def test_should_raise_value_error_on_invalid_delete_object_name( # Act & Assert for object_name in invalid_object_names: with pytest.raises(ValueError): - minio_adapter._delete(object_name) # type: ignore + minio_adapter.delete(object_name) # type: ignore def test_should_raise_connection_error_on_delete_when_not_connected( @@ -432,7 +430,7 @@ def test_should_raise_connection_error_on_delete_when_not_connected( adapter = MinioAdapter() # not connected # Act & Assert with pytest.raises(ConnectionError): - adapter._delete("some_object") + adapter.delete("some_object") def test_should_list_objects( @@ -444,9 +442,9 @@ def test_should_list_objects( object_name, _ = data_in_minio new_data = BytesIO(random.randbytes(2**21)) # 2 MiB new_data_name = "another_test_object" - minio_adapter._put(new_data_name, new_data) + minio_adapter.put(new_data_name, new_data) # Act - objects = minio_adapter._list_objects() + objects = minio_adapter.list_objects() # Assert assert isinstance(objects, list) assert len(objects) == 2 @@ -463,10 +461,10 @@ def test_should_list_objects_with_prefix( object_name, _ = data_in_minio new_data = BytesIO(random.randbytes(2**21)) # 2 MiB new_data_name = "prefix_test_object" - minio_adapter._put(new_data_name, new_data) + minio_adapter.put(new_data_name, new_data) prefix = "prefix_" # Act - objects = minio_adapter._list_objects(prefix) + objects = minio_adapter.list_objects(prefix) # Assert assert isinstance(objects, list) assert len(objects) == 1 @@ -483,7 +481,7 @@ def test_should_raise_value_error_on_invalid_list_objects_prefix( # Act & Assert for prefix in invalid_prefixes: with pytest.raises(ValueError): - minio_adapter._list_objects(prefix) # type: ignore + minio_adapter.list_objects(prefix) # type: ignore def test_should_raise_connection_error_on_list_objects_when_not_connected( @@ -494,7 +492,7 @@ def test_should_raise_connection_error_on_list_objects_when_not_connected( adapter = MinioAdapter() # not connected # Act & Assert with pytest.raises(ConnectionError): - adapter._list_objects() + adapter.list_objects() # allows local debugging by running file as script diff --git a/tests/integration/object_repository_interface_test.py b/tests/integration/object_repository_interface_test.py new file mode 100644 index 0000000..012545c --- /dev/null +++ b/tests/integration/object_repository_interface_test.py @@ -0,0 +1,99 @@ +"""Integration tests for ObjectRepositoryInterface.""" + +from io import BytesIO + +import pytest +from python_repositories.interfaces.object_repository_interface import ( + ObjectRepositoryInterface, +) + + +def test_instantiation_fails_when_get_not_implemented() -> None: + """Test that instantiation fails if get is not implemented.""" + + class Incomplete(ObjectRepositoryInterface): + """A class that does not implement get.""" + + 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 [] + + with pytest.raises(TypeError): + _ = Incomplete() # type: ignore + + +def test_instantiation_fails_when_put_not_implemented() -> None: + """Test that instantiation fails if put is not implemented.""" + + class Incomplete(ObjectRepositoryInterface): + """A class that does not implement put.""" + + def get(self, object_name: str) -> BytesIO | None: + return None + + def delete(self, object_name: str) -> None: + pass + + def list_objects(self, prefix: str = "") -> list[str]: + return [] + + with pytest.raises(TypeError): + _ = Incomplete() # type: ignore + + +def test_instantiation_fails_when_delete_not_implemented() -> None: + """Test that instantiation fails if delete is not implemented.""" + + class Incomplete(ObjectRepositoryInterface): + """A class that does not implement delete.""" + + 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 list_objects(self, prefix: str = "") -> list[str]: + return [] + + with pytest.raises(TypeError): + _ = Incomplete() # type: ignore + + +def test_instantiation_fails_when_list_objects_not_implemented() -> None: + """Test that instantiation fails if list_objects is not implemented.""" + + class Incomplete(ObjectRepositoryInterface): + """A class that does not implement list_objects.""" + + 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 + + with pytest.raises(TypeError): + _ = Incomplete() # type: ignore diff --git a/tests/integration/redis_adapter_test.py b/tests/integration/redis_adapter_test.py index 293ae9a..95ab9bb 100644 --- a/tests/integration/redis_adapter_test.py +++ b/tests/integration/redis_adapter_test.py @@ -1,6 +1,3 @@ -# pylint: disable=protected-access -# The above line disables pylint's protected member access warnings for this file, -# allowing tests to access RedisAdapter's internal methods as needed for integration testing. """Integration tests for the RedisAdapter.""" from collections.abc import Generator @@ -8,6 +5,7 @@ import pytest import redis from redis.commands.json.path import Path as RedisPath from python_repositories.adapters.redis_adapter import RedisAdapter +from python_repositories.interfaces import JsonRepositoryInterface @pytest.fixture(scope="module") @@ -50,7 +48,7 @@ def clear_redis(raw_redis_client: redis.Redis) -> None: def test_should_adhere_to_interface(redis_container: str) -> None: """Test that the RedisAdapter adheres to the expected interface.""" - # Instantiation fails if interface not adhered to + assert issubclass(RedisAdapter, JsonRepositoryInterface) _ = RedisAdapter() @@ -134,7 +132,7 @@ def test_should_get_value( # Arrange key, data = data_in_redis # Act - value = redis_adapter._get(key) + value = redis_adapter.get(key) # Assert assert value is not None assert value == data @@ -145,7 +143,7 @@ def test_should_get_none_for_missing_key( ) -> None: """Test that getting a non-existent key returns None.""" # Act - value = redis_adapter._get("nonexistent_key") + value = redis_adapter.get("nonexistent_key") # Assert assert value is None @@ -159,7 +157,7 @@ def test_should_raise_value_error_on_invalid_get_key( # Act & Assert for key in invalid_keys: with pytest.raises(ValueError): - redis_adapter._get(key) # type: ignore + redis_adapter.get(key) # type: ignore def test_should_raise_connection_error_on_get_when_not_connected( @@ -170,7 +168,7 @@ def test_should_raise_connection_error_on_get_when_not_connected( adapter = RedisAdapter() # not connected # Act & Assert with pytest.raises(ConnectionError): - adapter._get("some_key") + adapter.get("some_key") def test_should_set_value( @@ -180,12 +178,12 @@ def test_should_set_value( """Test that the RedisAdapter can set a value.""" # Arrange key = "test_key" - received_data = redis_adapter._get(key) + received_data = redis_adapter.get(key) assert received_data is None # Ensure key does not exist # Act - redis_adapter._set(key, data) + redis_adapter.set(key, data) # Assert - received_data = redis_adapter._get(key) + received_data = redis_adapter.get(key) assert received_data is not None assert received_data == data @@ -198,13 +196,13 @@ def test_should_update_value( # Arrange key, _ = data_in_redis new_data = {"new_key": "new_value"} - received_data = redis_adapter._get(key) + received_data = redis_adapter.get(key) assert received_data is not None assert received_data != new_data # Act - redis_adapter._set(key, new_data) + redis_adapter.set(key, new_data) # Assert - assert redis_adapter._get(key) == new_data + assert redis_adapter.get(key) == new_data def test_should_raise_value_error_on_invalid_set_key( @@ -217,7 +215,7 @@ def test_should_raise_value_error_on_invalid_set_key( # Act & Assert for key in invalid_keys: with pytest.raises(ValueError): - redis_adapter._set(key, data) # type: ignore + redis_adapter.set(key, data) # type: ignore def test_should_raise_value_error_on_invalid_set_data( @@ -230,7 +228,7 @@ def test_should_raise_value_error_on_invalid_set_data( # Act & Assert for data in invalid_data: with pytest.raises(ValueError): - redis_adapter._set(key, data) # type: ignore + redis_adapter.set(key, data) # type: ignore def test_should_raise_connection_error_on_set_when_not_connected( @@ -243,7 +241,7 @@ def test_should_raise_connection_error_on_set_when_not_connected( key = "test_key" # Act & Assert with pytest.raises(ConnectionError): - adapter._set(key, data) + adapter.set(key, data) def test_should_delete_key( @@ -253,12 +251,12 @@ def test_should_delete_key( """Test that deleting a key removes it from Redis.""" # Arrange key, _ = data_in_redis - received_data = redis_adapter._get(key) + received_data = redis_adapter.get(key) assert received_data is not None # Ensure key exists # Act - redis_adapter._delete(key) + redis_adapter.delete(key) # Assert - assert redis_adapter._get(key) is None + assert redis_adapter.get(key) is None def test_should_raise_value_error_on_invalid_delete_key( @@ -270,7 +268,7 @@ def test_should_raise_value_error_on_invalid_delete_key( # Act & Assert for key in invalid_keys: with pytest.raises(ValueError): - redis_adapter._delete(key) # type: ignore + redis_adapter.delete(key) # type: ignore def test_should_raise_connection_error_on_delete_when_not_connected( @@ -281,7 +279,7 @@ def test_should_raise_connection_error_on_delete_when_not_connected( adapter = RedisAdapter() # Act & Assert with pytest.raises(ConnectionError): - adapter._delete("some_key") + adapter.delete("some_key") def test_should_list_keys( @@ -289,10 +287,10 @@ def test_should_list_keys( ) -> None: """Test listing keys matching a pattern returns correct keys.""" # Arrange - redis_adapter._set("key1", {"a": 1}) - redis_adapter._set("key2", {"b": 2}) + redis_adapter.set("key1", {"a": 1}) + redis_adapter.set("key2", {"b": 2}) # Act - keys = redis_adapter._list_keys("key*") + keys = redis_adapter.list_keys("key*") # Assert assert set(keys) == {"key1", "key2"} @@ -306,7 +304,7 @@ def test_should_raise_value_error_on_invalid_list_keys_pattern( # Act & Assert for pattern in invalid_patterns: with pytest.raises(ValueError): - redis_adapter._list_keys(pattern) # type: ignore + redis_adapter.list_keys(pattern) # type: ignore def test_should_raise_connection_error_on_list_keys_when_not_connected( @@ -317,7 +315,7 @@ def test_should_raise_connection_error_on_list_keys_when_not_connected( adapter = RedisAdapter() # Act & Assert with pytest.raises(ConnectionError): - adapter._list_keys("some_pattern") + adapter.list_keys("some_pattern") # allows local debugging by running file as script diff --git a/uv.lock b/uv.lock index ba00dc3..ce86470 100644 --- a/uv.lock +++ b/uv.lock @@ -806,7 +806,7 @@ wheels = [ [[package]] name = "python-repositories" version = "0.1.0" -source = { virtual = "." } +source = { editable = "." } dependencies = [ { name = "python-utils" }, { name = "structlog" }, From 497533f0e33be3be8e62ba16b4c69614eb2b8229 Mon Sep 17 00:00:00 2001 From: Brian Bjarke Jensen Date: Sun, 28 Jun 2026 19:42:15 +0200 Subject: [PATCH 2/3] Fix mypy context manager typing for adapter subclasses. Return Self from __enter__ so with-blocks preserve subclass types, and align mypy python_version with the 3.12 runtime target. Co-authored-by: Cursor --- pyproject.toml | 2 +- python_repositories/adapters/minio_adapter.py | 3 ++- python_repositories/adapters/redis_adapter.py | 4 ++-- python_repositories/interfaces/context_aware_interface.py | 3 ++- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 6f1a724..b962891 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,7 +48,7 @@ url = "https://gitea.lille-vemmelund.dk/api/packages/brian/pypi/simple/" explicit = true [tool.mypy] -python_version = "3.10" +python_version = "3.12" warn_return_any = true # nudge to use stricter types warn_unused_configs = true # nudge to remove unused configs disallow_untyped_defs = true # disallow untyped function definitions diff --git a/python_repositories/adapters/minio_adapter.py b/python_repositories/adapters/minio_adapter.py index e0053e1..afcabb3 100644 --- a/python_repositories/adapters/minio_adapter.py +++ b/python_repositories/adapters/minio_adapter.py @@ -4,6 +4,7 @@ from __future__ import annotations import os from io import BytesIO from importlib.util import find_spec +from typing import Self import structlog from python_utils import check_env @@ -49,7 +50,7 @@ class MinioAdapter( self._client: minio.Minio | None = None self._bucket_name: str | None = None - def __enter__(self) -> MinioAdapter: + def __enter__(self) -> Self: """Enter the context.""" self.connect() return self diff --git a/python_repositories/adapters/redis_adapter.py b/python_repositories/adapters/redis_adapter.py index 5e0c455..56fa459 100644 --- a/python_repositories/adapters/redis_adapter.py +++ b/python_repositories/adapters/redis_adapter.py @@ -1,7 +1,7 @@ """Definition of RedisAdapter class.""" from __future__ import annotations -from typing import cast +from typing import Self, cast from importlib.util import find_spec import os import structlog @@ -42,7 +42,7 @@ class RedisAdapter( self._client: redis.Redis | None = None self.path: str = RedisPath.root_path() - def __enter__(self) -> RedisAdapter: + def __enter__(self) -> Self: """Enter the context.""" self.connect() return self diff --git a/python_repositories/interfaces/context_aware_interface.py b/python_repositories/interfaces/context_aware_interface.py index eaf5f71..7815304 100644 --- a/python_repositories/interfaces/context_aware_interface.py +++ b/python_repositories/interfaces/context_aware_interface.py @@ -3,13 +3,14 @@ from __future__ import annotations from abc import ABC, abstractmethod +from typing import Self class ContextAwareInterface(ABC): """Interface that defines context-related methods.""" @abstractmethod - def __enter__(self) -> ContextAwareInterface: + def __enter__(self) -> Self: """Enter the context.""" ... From c5169df10dbb01971d90800ee1a80c6dbf51cf99 Mon Sep 17 00:00:00 2001 From: Brian Bjarke Jensen Date: Sun, 28 Jun 2026 19:56:25 +0200 Subject: [PATCH 3/3] Use pre-commit for Prettier in CI and document local hook setup. Run prettier via pre-commit in code-quality workflow and add README instructions for installing and running hooks during development. Co-authored-by: Cursor --- .gitea/workflows/code-quality.yml | 4 +--- README.md | 37 +++++++++++++++++++------------ 2 files changed, 24 insertions(+), 17 deletions(-) diff --git a/.gitea/workflows/code-quality.yml b/.gitea/workflows/code-quality.yml index 8891941..1da84fc 100644 --- a/.gitea/workflows/code-quality.yml +++ b/.gitea/workflows/code-quality.yml @@ -39,6 +39,4 @@ jobs: run: uv run pyupgrade --py313-plus $(git ls-files '*.py') && git diff --exit-code - name: Prettier format - run: | - npm install --save-dev --save-exact prettier - npx prettier --check . + run: uv run pre-commit run prettier --all-files diff --git a/README.md b/README.md index 7440821..3baa6c8 100644 --- a/README.md +++ b/README.md @@ -6,11 +6,11 @@ Subclass an adapter in your own repository to add domain-specific methods while ## Architecture -| Layer | Responsibility | -|-------|----------------| -| **Interfaces** | Abstract contracts for connection, context, and CRUD | -| **Adapters** | Technology-specific base classes (`RedisAdapter`, `MinioAdapter`) | -| **Your project** | Subclass an adapter and add domain methods | +| Layer | Responsibility | +| ---------------- | ----------------------------------------------------------------- | +| **Interfaces** | Abstract contracts for connection, context, and CRUD | +| **Adapters** | Technology-specific base classes (`RedisAdapter`, `MinioAdapter`) | +| **Your project** | Subclass an adapter and add domain methods | ## Optional dependencies @@ -26,18 +26,18 @@ uv add python-repositories[redis,minio] Requires Redis with the RedisJSON module (e.g. redis-stack). -| Environment variable | Description | -|---------------------|-------------| -| `REDIS_URI` | Redis connection URL (e.g. `redis://localhost:6379`) | +| Environment variable | Description | +| -------------------- | ---------------------------------------------------- | +| `REDIS_URI` | Redis connection URL (e.g. `redis://localhost:6379`) | ### MinIO (`ObjectRepositoryInterface`) -| Environment variable | Description | -|---------------------|-------------| -| `MINIO_ENDPOINT` | MinIO server endpoint | -| `MINIO_ACCESS_KEY` | Access key | -| `MINIO_SECRET_KEY` | Secret key | -| `MINIO_BUCKET` | Bucket name (created on connect if missing) | +| Environment variable | Description | +| -------------------- | ------------------------------------------- | +| `MINIO_ENDPOINT` | MinIO server endpoint | +| `MINIO_ACCESS_KEY` | Access key | +| `MINIO_SECRET_KEY` | Secret key | +| `MINIO_BUCKET` | Bucket name (created on connect if missing) | ## Quick start @@ -99,7 +99,16 @@ from python_repositories import ( ```bash uv sync --all-extras +uv run pre-commit install # once per clone — runs hooks on git commit uv run pytest tests/integration/ -v ``` +`pre-commit` is included in the dev dependency group. `uv sync` installs the CLI, but git does not run hooks until you install them with `pre-commit install` (one time per clone). After that, commits run the checks defined in [`.pre-commit-config.yaml`](.pre-commit-config.yaml) (ruff, mypy, pyupgrade, prettier, and general file hygiene). + +To run all hooks manually without committing: + +```bash +uv run pre-commit run --all-files +``` + Integration tests require Docker (testcontainers).