Compare commits

...
10 Commits
Author SHA1 Message Date
CI Bot fef9552cbf chore: release v0.3.2 [skip ci] 2026-06-30 14:12:18 +00:00
brian e9b21895f4 Merge pull request '[patch] Fix optional dependency handling for Redis and MinIO adapters' (#19) from cursor/fix-optional-dependencies into main
Code Quality Pipeline / code-quality (push) Successful in 31s
Release on merge to main / release (push) Successful in 8s
Test Python Package / test (push) Successful in 37s
Reviewed-on: https://gitea.lille-vemmelund.dk/brian/python-repositories/pulls/19
2026-06-30 16:08:01 +02:00
Brian Bjarke JensenandCursor f5953906c1 Fix optional-deps import test for full-suite execution.
Code Quality Pipeline / code-quality (pull_request) Successful in 31s
PR Title Check / check-title (pull_request) Successful in 5s
Test Python Package / test (pull_request) Successful in 41s
Run the base-import assertion in a subprocess so integration tests do not pollute sys.modules.

Co-authored-by: Cursor <[email protected]>
2026-06-30 16:05:41 +02:00
Brian Bjarke JensenandCursor 1f02195c27 Delegate top-level adapter imports to adapters package.
Code Quality Pipeline / code-quality (pull_request) Successful in 47s
PR Title Check / check-title (pull_request) Successful in 6s
Test Python Package / test (pull_request) Failing after 1m1s
Keep lazy loading in adapters/__init__.py as the single place to register new adapters.

Co-authored-by: Cursor <[email protected]>
2026-06-30 15:57:52 +02:00
Brian Bjarke JensenandCursor f092ca2022 Fix trailing whitespace in LICENSE and .gitignore.
Code Quality Pipeline / code-quality (pull_request) Successful in 37s
PR Title Check / check-title (pull_request) Successful in 5s
Test Python Package / test (pull_request) Failing after 47s
Co-authored-by: Cursor <[email protected]>
2026-06-30 15:48:01 +02:00
Brian Bjarke JensenandCursor c6eed43d70 Fix optional dependency handling for Redis and MinIO adapters.
Lazy-load adapters at package boundaries and fail fast with install hints when extras are missing.

Co-authored-by: Cursor <[email protected]>
2026-06-30 15:47:04 +02:00
brian 9e4b1e2c5e Merge pull request 'Avoid testcontainers Redis deprecation warning in integration tests.' (#18) from fix/redis-testcontainer-deprecation into main
Code Quality Pipeline / code-quality (push) Successful in 31s
Release on merge to main / release (push) Successful in 6s
Test Python Package / test (push) Successful in 43s
Reviewed-on: https://gitea.lille-vemmelund.dk/brian/python-repositories/pulls/18
2026-06-29 15:04:39 +02:00
Brian Bjarke JensenandCursor 0096df78a5 Avoid testcontainers Redis deprecation warning in integration tests.
Code Quality Pipeline / code-quality (pull_request) Successful in 47s
PR Title Check / check-title (pull_request) Successful in 5s
Test Python Package / test (pull_request) Successful in 52s
Use a local RedisTestContainer with wait strategies instead of the deprecated RedisContainer, and keep conftest focused on fixtures.

Co-authored-by: Cursor <[email protected]>
2026-06-29 15:00:45 +02:00
brian 3073bfbf50 Merge pull request 'Fix PR coverage comment posting in CI.' (#17) from fix/coverage-pr-comment into main
Code Quality Pipeline / code-quality (push) Successful in 36s
Release on merge to main / release (push) Successful in 5s
Test Python Package / test (push) Successful in 47s
Reviewed-on: https://gitea.lille-vemmelund.dk/brian/python-repositories/pulls/17
2026-06-29 11:37:34 +02:00
Brian Bjarke JensenandCursor 30a1de32e8 Fix PR coverage comment posting in CI.
Test Python Package / test (pull_request) Successful in 48s
Code Quality Pipeline / code-quality (pull_request) Successful in 31s
PR Title Check / check-title (pull_request) Successful in 5s
Build the Gitea API payload with json.dumps so multiline coverage output is valid JSON, and fail the step when the API rejects the request.

Co-authored-by: Cursor <[email protected]>
2026-06-28 23:46:49 +02:00
12 changed files with 264 additions and 38 deletions
+15 -4
View File
@@ -40,9 +40,20 @@ jobs:
PR_NUMBER: ${{ github.event.pull_request.number }} PR_NUMBER: ${{ github.event.pull_request.number }}
CI_RUNNER_TOKEN: ${{ secrets.CI_RUNNER_TOKEN }} CI_RUNNER_TOKEN: ${{ secrets.CI_RUNNER_TOKEN }}
run: | run: |
COVERAGE=$(cat coverage.txt) PAYLOAD=$(python3 -c '
COMMENT_BODY="**Test Coverage Report:**\n\`\`\`\n$COVERAGE\n\`\`\`" import json
curl -s -X POST "$API_URL/repos/$REPO_OWNER/$REPO_NAME/issues/$PR_NUMBER/comments" \ import pathlib
coverage = pathlib.Path("coverage.txt").read_text()
print(
json.dumps(
{
"body": f"**Test Coverage Report:**\n```\n{coverage}\n```",
}
)
)
')
curl -sf -X POST "$API_URL/repos/$REPO_OWNER/$REPO_NAME/issues/$PR_NUMBER/comments" \
-H "Authorization: token $CI_RUNNER_TOKEN" \ -H "Authorization: token $CI_RUNNER_TOKEN" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d "{\"body\": \"$COMMENT_BODY\"}" -d "$PAYLOAD"
-1
View File
@@ -173,4 +173,3 @@ cython_debug/
# PyPI configuration file # PyPI configuration file
.pypirc .pypirc
+9 -9
View File
@@ -2,17 +2,17 @@ MIT License
Copyright (c) 2025 brian Copyright (c) 2025 brian
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
associated documentation files (the "Software"), to deal in the Software without restriction, including associated documentation files (the "Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
following conditions: following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial The above copyright notice and this permission notice shall be included in all copies or substantial
portions of the Software. portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
USE OR OTHER DEALINGS IN THE SOFTWARE. USE OR OTHER DEALINGS IN THE SOFTWARE.
+2
View File
@@ -14,6 +14,8 @@ Subclass an adapter in your own repository to add domain-specific methods while
## Optional dependencies ## Optional dependencies
Repository **interfaces** import with the base package. **Adapters** require the matching extra; importing an adapter without its extra raises `ImportError` with install instructions.
Install with the extras you need: Install with the extras you need:
```bash ```bash
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "python-repositories" name = "python-repositories"
version = "0.3.1" version = "0.3.2"
description = "Various python repository interfaces exposed as a python package." description = "Various python repository interfaces exposed as a python package."
authors = [ authors = [
{ name = "Brian Bjarke Jensen", email = "[email protected]" } { name = "Brian Bjarke Jensen", email = "[email protected]" }
+24 -3
View File
@@ -1,6 +1,11 @@
"""python_repositories: Unified repository interfaces and adapters.""" """python_repositories: Unified repository interfaces and adapters."""
from .adapters import MinioAdapter, RedisAdapter from __future__ import annotations
from typing import TYPE_CHECKING
# Interfaces are always available; they have no optional backend dependencies.
from . import adapters
from .interfaces import ( from .interfaces import (
ConnectionAwareInterface, ConnectionAwareInterface,
ContextAwareInterface, ContextAwareInterface,
@@ -8,11 +13,27 @@ from .interfaces import (
ObjectRepositoryInterface, ObjectRepositoryInterface,
) )
# Adapters are imported only for static type checkers; runtime loading is delegated below.
if TYPE_CHECKING:
from .adapters.minio_adapter import MinioAdapter as MinioAdapter
from .adapters.redis_adapter import RedisAdapter as RedisAdapter
__all__ = [ __all__ = [
"ConnectionAwareInterface", "ConnectionAwareInterface",
"ContextAwareInterface", "ContextAwareInterface",
"JsonRepositoryInterface", "JsonRepositoryInterface",
"ObjectRepositoryInterface", "ObjectRepositoryInterface",
"RedisAdapter", *adapters.__all__,
"MinioAdapter",
] ]
def __getattr__(name: str) -> object:
"""Delegate adapter lookups to adapters; lazy loading is defined there."""
if name in adapters.__all__:
return getattr(adapters, name)
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
def __dir__() -> list[str]:
"""Expose lazy adapter names in tab completion and dir()."""
return sorted(__all__)
+31 -2
View File
@@ -4,10 +4,39 @@ Adapters for various backend repositories (e.g., Redis, Minio).
This module exposes concrete implementations for repository interfaces. This module exposes concrete implementations for repository interfaces.
""" """
from .redis_adapter import RedisAdapter from __future__ import annotations
from .minio_adapter import MinioAdapter
import importlib
from typing import TYPE_CHECKING
# Adapters are imported only for static type checkers; runtime loading is deferred below.
if TYPE_CHECKING:
from .minio_adapter import MinioAdapter
from .redis_adapter import RedisAdapter
# Map public adapter names to their defining module and class.
# Each adapter module fails fast with an install hint if its extra is missing.
# When adding a new adapter, update this dict and __all__ only.
_LAZY_EXPORTS = {
"RedisAdapter": (".redis_adapter", "RedisAdapter"),
"MinioAdapter": (".minio_adapter", "MinioAdapter"),
}
__all__ = [ __all__ = [
"RedisAdapter", "RedisAdapter",
"MinioAdapter", "MinioAdapter",
] ]
def __getattr__(name: str) -> object:
"""Load an adapter on first access so the base package installs without backend clients."""
if name in _LAZY_EXPORTS:
module_path, attr = _LAZY_EXPORTS[name]
module = importlib.import_module(module_path, __package__)
return getattr(module, attr)
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
def __dir__() -> list[str]:
"""Expose lazy adapter names in tab completion and dir()."""
return sorted(__all__)
@@ -3,7 +3,6 @@
from __future__ import annotations from __future__ import annotations
import os import os
from io import BytesIO from io import BytesIO
from importlib.util import find_spec
from typing import Self from typing import Self
import structlog import structlog
from python_utils import check_env from python_utils import check_env
@@ -14,9 +13,13 @@ from python_repositories.interfaces import (
ObjectRepositoryInterface, ObjectRepositoryInterface,
) )
# Handle optional dependencies try:
if find_spec("minio") is not None:
import minio import minio
except ImportError as exc:
raise ImportError(
"MinIO support requires the minio extra. "
"Install with: pip install python-repositories[minio]"
) from exc
class MinioAdapter( class MinioAdapter(
@@ -112,7 +115,7 @@ class MinioAdapter(
@property @property
def is_connected(self) -> bool: def is_connected(self) -> bool:
"""Check if connected to Minio server.""" """Check if connected to Minio server."""
res = bool(isinstance(self._client, minio.Minio)) res = self._client is not None
self.logger.debug(res) self.logger.debug(res)
return res return res
@@ -2,7 +2,6 @@
from __future__ import annotations from __future__ import annotations
from typing import Self, cast from typing import Self, cast
from importlib.util import find_spec
import os import os
import structlog import structlog
@@ -14,10 +13,14 @@ from python_repositories.interfaces import (
JsonRepositoryInterface, JsonRepositoryInterface,
) )
# Handle optional dependencies try:
if find_spec("redis") is not None:
import redis import redis
from redis.commands.json.path import Path as RedisPath from redis.commands.json.path import Path as RedisPath
except ImportError as exc:
raise ImportError(
"Redis support requires the redis extra. "
"Install with: pip install python-repositories[redis]"
) from exc
class RedisAdapter( class RedisAdapter(
@@ -90,7 +93,7 @@ class RedisAdapter(
@property @property
def is_connected(self) -> bool: def is_connected(self) -> bool:
"""Check if connected to Redis server.""" """Check if connected to Redis server."""
res = bool(isinstance(self._client, redis.Redis)) res = self._client is not None
self.logger.debug(res) self.logger.debug(res)
return res return res
+13 -10
View File
@@ -1,16 +1,19 @@
"""Integration tests configuration.""" """Integration tests configuration."""
import logging
import os import os
from collections.abc import Generator from collections.abc import Generator
import pytest import pytest
import redis import redis
import structlog import structlog
import logging
from minio import Minio from minio import Minio
from testcontainers.redis import RedisContainer
from testcontainers.minio import MinioContainer from testcontainers.minio import MinioContainer
from tests.integration.redis_container_test import REDIS_PORT, RedisTestContainer
collect_ignore = ["redis_container_test.py"]
MINIO_ACCESS_KEY = "minioadmin" MINIO_ACCESS_KEY = "minioadmin"
MINIO_SECRET_KEY = "minioadmin" MINIO_SECRET_KEY = "minioadmin"
MINIO_BUCKET = "test-bucket" MINIO_BUCKET = "test-bucket"
@@ -37,16 +40,16 @@ def configure_logging() -> None:
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
def redis_container() -> Generator[str]: def redis_container() -> Generator[str, None, None]:
"""Set up a Redis container for testing and yield the Redis URI.""" """Set up a Redis container for testing and yield the Redis URI."""
# Start container # Start container
container = RedisContainer( container = RedisTestContainer(
image="redis/redis-stack:7.2.0-v0", image="redis/redis-stack:7.2.0-v0",
) )
container.start() container.start()
# Set environment variable for Redis URI # Set environment variable for Redis URI
redis_host = container.get_container_host_ip() redis_host = container.get_container_host_ip()
redis_port = container.get_exposed_port(6379) redis_port = container.get_exposed_port(REDIS_PORT)
redis_uri = f"redis://{redis_host}:{redis_port}" redis_uri = f"redis://{redis_host}:{redis_port}"
yield redis_uri yield redis_uri
@@ -56,7 +59,7 @@ def redis_container() -> Generator[str]:
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
def minio_container() -> Generator[dict[str, str]]: def minio_container() -> Generator[dict[str, str], None, None]:
"""Set up a Minio container for testing and yield the Minio URI.""" """Set up a Minio container for testing and yield the Minio URI."""
# Start container # Start container
container = MinioContainer( container = MinioContainer(
@@ -86,7 +89,7 @@ def minio_container() -> Generator[dict[str, str]]:
def set_environment_variables( def set_environment_variables(
redis_container: str, redis_container: str,
minio_container: dict[str, str], minio_container: dict[str, str],
) -> Generator[dict[str, str]]: ) -> Generator[dict[str, str], None, None]:
"""Set environment variables needed for tests.""" """Set environment variables needed for tests."""
# Build environment variables dictionary # Build environment variables dictionary
env_vars = {"REDIS_URI": redis_container} env_vars = {"REDIS_URI": redis_container}
@@ -103,7 +106,7 @@ def set_environment_variables(
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
def raw_redis_client(redis_container: str) -> Generator[redis.Redis]: def raw_redis_client(redis_container: str) -> Generator[redis.Redis, None, None]:
"""Provide a raw Redis client connected to the test Redis container.""" """Provide a raw Redis client connected to the test Redis container."""
# Connect client # Connect client
client = redis.Redis.from_url( client = redis.Redis.from_url(
@@ -119,7 +122,7 @@ def raw_redis_client(redis_container: str) -> Generator[redis.Redis]:
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
def raw_minio_client(minio_container: dict[str, str]) -> Generator[Minio]: def raw_minio_client(minio_container: dict[str, str]) -> Generator[Minio, None, None]:
"""Provide a raw Minio client connected to the test Minio container.""" """Provide a raw Minio client connected to the test Minio container."""
# Connect client # Connect client
client = Minio( client = Minio(
+36
View File
@@ -0,0 +1,36 @@
"""Redis test container without testcontainers' deprecated wait decorator."""
from typing import Any, cast
import redis
from testcontainers.core.container import DockerContainer
from testcontainers.core.waiting_utils import WaitStrategy, WaitStrategyTarget
REDIS_PORT = 6379
class _RedisPingWaitStrategy(WaitStrategy):
def __init__(self) -> None:
super().__init__()
self.with_transient_exceptions(redis.exceptions.ConnectionError)
def wait_until_ready(self, container: WaitStrategyTarget) -> None:
redis_container = cast("RedisTestContainer", container)
if not self._poll(lambda: redis_container.get_client().ping()):
raise redis.exceptions.ConnectionError("Could not connect to Redis")
class RedisTestContainer(DockerContainer):
"""Redis container using wait strategies instead of the deprecated decorator."""
def __init__(self, image: str, port: int = REDIS_PORT) -> None:
super().__init__(image, _wait_strategy=_RedisPingWaitStrategy())
self.port = port
self.with_exposed_ports(self.port)
def get_client(self, **kwargs: Any) -> redis.Redis:
return redis.Redis(
host=self.get_container_host_ip(),
port=self.get_exposed_port(self.port),
**kwargs,
)
+119
View File
@@ -0,0 +1,119 @@
"""Tests for optional dependency import behavior."""
from __future__ import annotations
import builtins
import importlib
import os
import subprocess
import sys
from collections.abc import Callable, Mapping, Sequence
from pathlib import Path
from types import ModuleType
from unittest.mock import patch
import pytest
import python_repositories
_ROOT = Path(__file__).resolve().parents[2]
def _block_backend_import(blocked_prefix: str) -> Callable[..., ModuleType]:
real_import = builtins.__import__
def fake_import(
name: str,
globals: Mapping[str, object] | None = None,
locals: Mapping[str, object] | None = None,
fromlist: Sequence[str] = (),
level: int = 0,
) -> ModuleType:
if name == blocked_prefix or name.startswith(f"{blocked_prefix}."):
raise ImportError(f"No module named '{name}'")
return real_import(name, globals, locals, fromlist, level)
return fake_import
def test_base_import_does_not_load_adapters() -> None:
"""Base package import does not eagerly load backend adapter modules."""
script = """
import sys
from python_repositories import JsonRepositoryInterface
assert JsonRepositoryInterface is not None
assert "python_repositories.adapters.redis_adapter" not in sys.modules
assert "python_repositories.adapters.minio_adapter" not in sys.modules
"""
result = subprocess.run(
[sys.executable, "-c", script],
cwd=_ROOT,
env={**os.environ, "PYTHONPATH": str(_ROOT)},
capture_output=True,
text=True,
)
assert result.returncode == 0, result.stderr or result.stdout
def test_lazy_adapter_load_succeeds_when_extra_present() -> None:
"""Adapters load when their optional dependencies are installed."""
from python_repositories import MinioAdapter, RedisAdapter
assert RedisAdapter.__name__ == "RedisAdapter"
assert MinioAdapter.__name__ == "MinioAdapter"
def test_redis_adapter_import_error_without_extra() -> None:
"""Missing redis extra raises ImportError with install hint."""
import python_repositories.adapters.redis_adapter as redis_adapter_module
with patch.object(builtins, "__import__", new=_block_backend_import("redis")):
with pytest.raises(ImportError, match=r"python-repositories\[redis\]"):
importlib.reload(redis_adapter_module)
importlib.reload(redis_adapter_module)
def test_minio_adapter_import_error_without_extra() -> None:
"""Missing minio extra raises ImportError with install hint."""
import python_repositories.adapters.minio_adapter as minio_adapter_module
with patch.object(builtins, "__import__", new=_block_backend_import("minio")):
with pytest.raises(ImportError, match=r"python-repositories\[minio\]"):
importlib.reload(minio_adapter_module)
importlib.reload(minio_adapter_module)
def test_top_level_lazy_import_propagates_redis_import_error() -> None:
"""Top-level RedisAdapter access surfaces adapter import errors."""
with patch(
"importlib.import_module",
side_effect=ImportError(
"Redis support requires the redis extra. "
"Install with: pip install python-repositories[redis]"
),
):
with pytest.raises(ImportError, match=r"python-repositories\[redis\]"):
_ = python_repositories.RedisAdapter
def test_top_level_lazy_import_propagates_minio_import_error() -> None:
"""Top-level MinioAdapter access surfaces adapter import errors."""
with patch(
"importlib.import_module",
side_effect=ImportError(
"MinIO support requires the minio extra. "
"Install with: pip install python-repositories[minio]"
),
):
with pytest.raises(ImportError, match=r"python-repositories\[minio\]"):
_ = python_repositories.MinioAdapter
def test_adapters_subpackage_lazy_import_succeeds() -> None:
"""Adapter subpackage imports delegate to the same lazy loader."""
from python_repositories.adapters import RedisAdapter
assert RedisAdapter.__name__ == "RedisAdapter"