Compare commits

..
6 Commits
Author SHA1 Message Date
CI Bot c2e6a96c5d chore: release v2.0.0 [skip ci] 2026-07-09 13:17:19 +00:00
brian 547a1b3a90 Merge pull request '[breaking] Make MinIO bucket creation opt-in with production-safe defaults' (#36) from cursor/minio-bucket-creation-opt-in into main
Code Quality Pipeline / code-quality (push) Successful in 41s
Release on merge to main / release (push) Successful in 25s
Test Python Package / unit-tests (push) Successful in 17s
Test Python Package / integration-tests (push) Successful in 1m44s
Test Python Package / coverage-report (push) Successful in 23s
Reviewed-on: https://gitea.lille-vemmelund.dk/brian/python-repositories/pulls/36
2026-07-09 15:16:54 +02:00
Brian Bjarke JensenandCursor 39baf9badc Fix examples integration test by provisioning MinIO bucket in fixture.
Test Python Package / unit-tests (pull_request) Successful in 18s
PR Title Check / check-title (pull_request) Successful in 34s
Test Python Package / integration-tests (pull_request) Successful in 33s
Code Quality Pipeline / code-quality (pull_request) Successful in 49s
Test Python Package / coverage-report (pull_request) Successful in 51s
Ensure set_example_env depends on raw_minio_client so ArtifactObjectRepository connects against an existing bucket under the new production-safe default.

Co-authored-by: Cursor <[email protected]>
2026-07-09 15:06:30 +02:00
Brian Bjarke JensenandCursor 4a5b3b631f Make MinIO bucket creation opt-in with production-safe defaults.
PR Title Check / check-title (pull_request) Successful in 6s
Test Python Package / unit-tests (pull_request) Successful in 18s
Code Quality Pipeline / code-quality (pull_request) Successful in 1m28s
Test Python Package / integration-tests (pull_request) Failing after 1m54s
Test Python Package / coverage-report (pull_request) Has been skipped
Require buckets to exist by default on connect, extract env_bool parsing, and document local dev overrides for secure and auto-creation settings.

Co-authored-by: Cursor <[email protected]>
2026-07-09 14:52:14 +02:00
brian 1431f455c1 Merge pull request 'Move Redis test container helpers into integration conftest' (#35) from cursor/move-redis-container-to-conftest into main
Release on merge to main / release (push) Successful in 6s
Test Python Package / unit-tests (push) Successful in 21s
Code Quality Pipeline / code-quality (push) Successful in 28s
Test Python Package / integration-tests (push) Successful in 50s
Test Python Package / coverage-report (push) Successful in 1m19s
Reviewed-on: https://gitea.lille-vemmelund.dk/brian/python-repositories/pulls/35
2026-07-08 21:23:48 +02:00
Brian Bjarke JensenandCursor eafc717045 Move Redis test container helpers into integration conftest.
PR Title Check / check-title (pull_request) Successful in 5s
Test Python Package / unit-tests (pull_request) Successful in 14s
Code Quality Pipeline / code-quality (pull_request) Successful in 31s
Test Python Package / integration-tests (pull_request) Successful in 1m9s
Test Python Package / coverage-report (pull_request) Successful in 29s
Colocate RedisTestContainer with the fixtures that use it and remove the misleading redis_container_test module plus collect_ignore workaround.

Co-authored-by: Cursor <[email protected]>
2026-07-08 21:17:33 +02:00
15 changed files with 209 additions and 91 deletions
+1
View File
@@ -7,3 +7,4 @@ MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=minioadmin MINIO_SECRET_KEY=minioadmin
MINIO_BUCKET=my-bucket MINIO_BUCKET=my-bucket
MINIO_SECURE=false MINIO_SECURE=false
MINIO_CREATE_BUCKET_IF_MISSING=true
+11 -7
View File
@@ -48,16 +48,19 @@ for key in repo.scan_keys("user:*"):
### MinIO (`ObjectRepositoryInterface`) ### MinIO (`ObjectRepositoryInterface`)
| Environment variable | Description | | Environment variable | Description |
| -------------------- | ------------------------------------------- | | -------------------------------- | ------------------------------------------------------------------------ |
| `MINIO_ENDPOINT` | MinIO server endpoint | | `MINIO_ENDPOINT` | MinIO server endpoint |
| `MINIO_ACCESS_KEY` | Access key | | `MINIO_ACCESS_KEY` | Access key |
| `MINIO_SECRET_KEY` | Secret key | | `MINIO_SECRET_KEY` | Secret key |
| `MINIO_BUCKET` | Bucket name (created on connect if missing) | | `MINIO_BUCKET` | Bucket name |
| `MINIO_SECURE` | Use HTTPS (`true`/`false`; default: `true`) | | `MINIO_SECURE` | Use HTTPS (`true`/`false`; default: `true`) |
| `MINIO_CREATE_BUCKET_IF_MISSING` | Auto-create `MINIO_BUCKET` on connect (`true`/`false`; default: `false`) |
Copy [`.env.example`](.env.example) to `.env` for local development. `RedisConfig.from_env()` and `MinioConfig.from_env()` load `.env` automatically when resolving configuration from the environment. Copy [`.env.example`](.env.example) to `.env` for local development. `RedisConfig.from_env()` and `MinioConfig.from_env()` load `.env` automatically when resolving configuration from the environment.
For production, it is recommended to leave `MINIO_CREATE_BUCKET_IF_MISSING` unset so that `connect()` fails fast if the expected bucket is missing. For local development, you will often want `MINIO_SECURE=false` and `MINIO_CREATE_BUCKET_IF_MISSING=true`.
## Configuration injection ## Configuration injection
Adapters accept optional `config` and `client` keyword arguments for explicit setup and testing: Adapters accept optional `config` and `client` keyword arguments for explicit setup and testing:
@@ -73,6 +76,7 @@ minio = MinioAdapter(
secret_key="minioadmin", secret_key="minioadmin",
bucket="my-bucket", bucket="my-bucket",
secure=False, secure=False,
# create_bucket_if_missing=True, # convenient for local dev
) )
) )
``` ```
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "python-repositories" name = "python-repositories"
version = "1.1.0" version = "2.0.0"
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]" }
@@ -26,6 +26,7 @@ class MinioAdapter(ObjectRepositoryInterface, ConnectionAwareAdapter):
secret_key_env_var_name: str = "MINIO_SECRET_KEY" secret_key_env_var_name: str = "MINIO_SECRET_KEY"
bucket_env_var_name: str = "MINIO_BUCKET" bucket_env_var_name: str = "MINIO_BUCKET"
secure_env_var_name: str = "MINIO_SECURE" secure_env_var_name: str = "MINIO_SECURE"
create_bucket_if_missing_env_var_name: str = "MINIO_CREATE_BUCKET_IF_MISSING"
chunk_size: int = 5 * 2**20 # 5 MiB chunk_size: int = 5 * 2**20 # 5 MiB
connection_name: str = "Minio" connection_name: str = "Minio"
@@ -36,15 +37,16 @@ class MinioAdapter(ObjectRepositoryInterface, ConnectionAwareAdapter):
client: minio.Minio | None = None, client: minio.Minio | None = None,
) -> None: ) -> None:
super().__init__() super().__init__()
if client is not None and config is None:
raise ValueError("config is required when client is provided")
if config is None: if config is None:
if client is not None:
raise ValueError("config is required when client is provided")
config = MinioConfig.from_env( config = MinioConfig.from_env(
self.endpoint_env_var_name, self.endpoint_env_var_name,
self.access_key_env_var_name, self.access_key_env_var_name,
self.secret_key_env_var_name, self.secret_key_env_var_name,
self.bucket_env_var_name, self.bucket_env_var_name,
self.secure_env_var_name, self.secure_env_var_name,
self.create_bucket_if_missing_env_var_name,
) )
self._config = config self._config = config
self._client_injected = client is not None self._client_injected = client is not None
@@ -88,6 +90,10 @@ class MinioAdapter(ObjectRepositoryInterface, ConnectionAwareAdapter):
except Exception as exc: # pylint: disable=broad-except except Exception as exc: # pylint: disable=broad-except
raise ConnectionError(f"Could not connect to Minio at {endpoint}") from exc raise ConnectionError(f"Could not connect to Minio at {endpoint}") from exc
if not client.bucket_exists(bucket): if not client.bucket_exists(bucket):
if not self._config.create_bucket_if_missing:
raise ConnectionError(
f"Bucket '{bucket}' does not exist on Minio at {endpoint}"
)
self.logger.info(f"Creating bucket '{bucket}'") self.logger.info(f"Creating bucket '{bucket}'")
client.make_bucket(bucket) client.make_bucket(bucket)
self._client = client self._client = client
@@ -35,9 +35,9 @@ class RedisAdapter(JsonRepositoryInterface, ConnectionAwareAdapter):
client: redis.Redis | None = None, client: redis.Redis | None = None,
) -> None: ) -> None:
super().__init__() super().__init__()
if client is not None and config is None:
raise ValueError("config is required when client is provided")
if config is None: if config is None:
if client is not None:
raise ValueError("config is required when client is provided")
config = RedisConfig.from_env(self.uri_env_var_name) config = RedisConfig.from_env(self.uri_env_var_name)
self._config = config self._config = config
self._client_injected = client is not None self._client_injected = client is not None
+21
View File
@@ -0,0 +1,21 @@
"""Parse boolean values from environment variables."""
from __future__ import annotations
import os
_TRUTHY = frozenset({"1", "true", "yes", "on"})
_FALSY = frozenset({"0", "false", "no", "off"})
def env_bool(name: str, default: bool) -> bool:
"""Parse an environment variable as a boolean value."""
raw = os.getenv(name)
if raw is None:
return default
normalized = raw.strip().lower()
if normalized in _TRUTHY:
return True
if normalized in _FALSY:
return False
raise ValueError(f"Invalid boolean value for {name}: {raw!r}")
+8 -16
View File
@@ -8,21 +8,7 @@ from dataclasses import dataclass
from python_utils import check_env from python_utils import check_env
from python_repositories.config.dotenv_loader import load_dotenv from python_repositories.config.dotenv_loader import load_dotenv
from python_repositories.config.env_bool import env_bool
_TRUTHY = frozenset({"1", "true", "yes", "on"})
_FALSY = frozenset({"0", "false", "no", "off"})
def _env_bool(name: str, default: bool) -> bool:
raw = os.getenv(name)
if raw is None:
return default
normalized = raw.strip().lower()
if normalized in _TRUTHY:
return True
if normalized in _FALSY:
return False
raise ValueError(f"Invalid boolean value for {name}: {raw!r}")
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -34,6 +20,7 @@ class MinioConfig:
secret_key: str secret_key: str
bucket: str bucket: str
secure: bool = True secure: bool = True
create_bucket_if_missing: bool = False
@classmethod @classmethod
def from_env( def from_env(
@@ -43,6 +30,7 @@ class MinioConfig:
secret_key_env_var_name: str = "MINIO_SECRET_KEY", secret_key_env_var_name: str = "MINIO_SECRET_KEY",
bucket_env_var_name: str = "MINIO_BUCKET", bucket_env_var_name: str = "MINIO_BUCKET",
secure_env_var_name: str = "MINIO_SECURE", secure_env_var_name: str = "MINIO_SECURE",
create_bucket_if_missing_env_var_name: str = ("MINIO_CREATE_BUCKET_IF_MISSING"),
*, *,
use_dotenv: bool = True, use_dotenv: bool = True,
) -> MinioConfig: ) -> MinioConfig:
@@ -61,5 +49,9 @@ class MinioConfig:
access_key=str(os.getenv(access_key_env_var_name)), access_key=str(os.getenv(access_key_env_var_name)),
secret_key=str(os.getenv(secret_key_env_var_name)), secret_key=str(os.getenv(secret_key_env_var_name)),
bucket=str(os.getenv(bucket_env_var_name)), bucket=str(os.getenv(bucket_env_var_name)),
secure=_env_bool(secure_env_var_name, default=True), secure=env_bool(secure_env_var_name, default=True),
create_bucket_if_missing=env_bool(
create_bucket_if_missing_env_var_name,
default=False,
),
) )
+31 -2
View File
@@ -2,23 +2,52 @@
import logging import logging
from collections.abc import Generator from collections.abc import Generator
from typing import Any, cast
import pytest import pytest
import redis import redis
import structlog import structlog
from minio import Minio from minio import Minio
from testcontainers.core.container import DockerContainer
from testcontainers.core.waiting_utils import WaitStrategy, WaitStrategyTarget
from testcontainers.minio import MinioContainer from testcontainers.minio import MinioContainer
from python_repositories.config import MinioConfig, RedisConfig from python_repositories.config import MinioConfig, RedisConfig
from tests.integration.redis_container_test import REDIS_PORT, RedisTestContainer
collect_ignore = ["redis_container_test.py"] REDIS_PORT = 6379
MINIO_ACCESS_KEY = "minioadmin" MINIO_ACCESS_KEY = "minioadmin"
MINIO_SECRET_KEY = "minioadmin" MINIO_SECRET_KEY = "minioadmin"
MINIO_BUCKET = "test-bucket" MINIO_BUCKET = "test-bucket"
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,
)
@pytest.fixture(scope="session", autouse=True) @pytest.fixture(scope="session", autouse=True)
def configure_logging() -> None: def configure_logging() -> None:
"""Configure logging for the test session.""" """Configure logging for the test session."""
+3
View File
@@ -6,6 +6,7 @@ import os
import random import random
import pytest import pytest
from minio import Minio
from python_repositories.examples.artifact_object_repository import ( from python_repositories.examples.artifact_object_repository import (
ArtifactObjectRepository, ArtifactObjectRepository,
@@ -19,8 +20,10 @@ pytestmark = pytest.mark.integration
def set_example_env( def set_example_env(
redis_container: str, redis_container: str,
minio_container: dict[str, str], minio_container: dict[str, str],
raw_minio_client: Minio,
) -> Generator[None, None, None]: ) -> Generator[None, None, None]:
"""Set env vars so example repositories can use from_env() defaults.""" """Set env vars so example repositories can use from_env() defaults."""
_ = raw_minio_client
env_vars = {"REDIS_URI": redis_container, **minio_container} env_vars = {"REDIS_URI": redis_container, **minio_container}
for key, value in env_vars.items(): for key, value in env_vars.items():
os.environ[key] = value os.environ[key] = value
+24 -3
View File
@@ -1,6 +1,7 @@
"""Integration tests for the MinioAdapter.""" """Integration tests for the MinioAdapter."""
from collections.abc import Generator from collections.abc import Generator
from dataclasses import replace
import logging import logging
import random import random
from io import BytesIO from io import BytesIO
@@ -118,17 +119,37 @@ def test_should_raise_connection_error_when_unable_to_connect() -> None:
assert not adapter.is_connected() assert not adapter.is_connected()
def test_should_log_info_when_creating_expected_bucket( def test_connect_raises_when_bucket_missing(
raw_minio_client: Minio,
minio_config: MinioConfig,
) -> None:
"""Test that connect fails when the configured bucket is missing."""
bucket_name = minio_config.bucket
raw_minio_client.remove_bucket(bucket_name)
adapter = MinioAdapter(config=minio_config)
try:
with pytest.raises(ConnectionError, match="does not exist"):
adapter.connect()
assert not adapter.is_connected()
finally:
raw_minio_client.make_bucket(bucket_name)
def test_connect_creates_bucket_when_create_bucket_if_missing_enabled(
raw_minio_client: Minio, raw_minio_client: Minio,
minio_config: MinioConfig, minio_config: MinioConfig,
caplog: pytest.LogCaptureFixture, caplog: pytest.LogCaptureFixture,
) -> None: ) -> None:
"""Test that the MinioAdapter logs info when creating the expected bucket.""" """Test that connect can create the configured bucket when enabled."""
bucket_name = minio_config.bucket bucket_name = minio_config.bucket
raw_minio_client.remove_bucket(bucket_name) raw_minio_client.remove_bucket(bucket_name)
adapter = MinioAdapter(config=minio_config) config = replace(minio_config, create_bucket_if_missing=True)
adapter = MinioAdapter(config=config)
with caplog.at_level(logging.INFO): with caplog.at_level(logging.INFO):
adapter.connect() adapter.connect()
assert f"Creating bucket '{bucket_name}'" in caplog.text assert f"Creating bucket '{bucket_name}'" in caplog.text
-36
View File
@@ -1,36 +0,0 @@
"""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,
)
+43
View File
@@ -0,0 +1,43 @@
"""Unit tests for env_bool."""
from __future__ import annotations
import pytest
from python_repositories.config.env_bool import env_bool
@pytest.mark.parametrize(
("value", "expected"),
[
("true", True),
("1", True),
("yes", True),
("on", True),
("false", False),
("0", False),
("no", False),
("off", False),
],
)
def test_env_bool_parses_truthy_and_falsy(
monkeypatch: pytest.MonkeyPatch,
value: str,
expected: bool,
) -> None:
monkeypatch.setenv("TEST_BOOL", value)
assert env_bool("TEST_BOOL", default=not expected) is expected
def test_env_bool_returns_default_when_unset(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("TEST_BOOL", raising=False)
assert env_bool("TEST_BOOL", default=True) is True
assert env_bool("TEST_BOOL", default=False) is False
def test_env_bool_raises_for_invalid_value(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("TEST_BOOL", "not-a-bool")
with pytest.raises(ValueError, match="TEST_BOOL"):
env_bool("TEST_BOOL", default=False)
+39
View File
@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import replace
from unittest.mock import MagicMock from unittest.mock import MagicMock
import pytest import pytest
@@ -94,6 +95,44 @@ def test_connect_disconnects_before_reconnect(
new_client.list_buckets.assert_called_once() new_client.list_buckets.assert_called_once()
def test_connect_raises_when_bucket_missing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
mock_client = MagicMock(spec=Minio)
mock_client.bucket_exists.return_value = False
monkeypatch.setattr(
"python_repositories.adapters.minio_adapter.minio.Minio",
lambda *args, **kwargs: mock_client,
)
adapter = MinioAdapter(config=TEST_MINIO_CONFIG)
with pytest.raises(ConnectionError, match="does not exist"):
adapter.connect()
mock_client.make_bucket.assert_not_called()
def test_connect_creates_bucket_when_create_bucket_if_missing_enabled(
monkeypatch: pytest.MonkeyPatch,
) -> None:
mock_client = MagicMock(spec=Minio)
mock_client.bucket_exists.return_value = False
config = replace(TEST_MINIO_CONFIG, create_bucket_if_missing=True)
monkeypatch.setattr(
"python_repositories.adapters.minio_adapter.minio.Minio",
lambda *args, **kwargs: mock_client,
)
adapter = MinioAdapter(config=config)
adapter.connect()
mock_client.bucket_exists.assert_called_once_with(config.bucket)
mock_client.make_bucket.assert_called_once_with(config.bucket)
def test_get_closes_response_on_success() -> None: def test_get_closes_response_on_success() -> None:
"""get() must close and release the get_object HTTP response.""" """get() must close and release the get_object HTTP response."""
mock_client = MagicMock(spec=Minio) mock_client = MagicMock(spec=Minio)
+16 -21
View File
@@ -22,6 +22,7 @@ def test_from_env_loads_all_fields(monkeypatch: pytest.MonkeyPatch) -> None:
assert config.secret_key == "secret" assert config.secret_key == "secret"
assert config.bucket == "my-bucket" assert config.bucket == "my-bucket"
assert config.secure is True assert config.secure is True
assert config.create_bucket_if_missing is False
def test_from_env_defaults_secure_to_true_when_unset( def test_from_env_defaults_secure_to_true_when_unset(
@@ -33,33 +34,27 @@ def test_from_env_defaults_secure_to_true_when_unset(
assert config.secure is True assert config.secure is True
@pytest.mark.parametrize( def test_from_env_reads_secure_from_env(monkeypatch: pytest.MonkeyPatch) -> None:
("value", "expected"), _set_required_minio_env(monkeypatch)
[ monkeypatch.setenv("MINIO_SECURE", "false")
("true", True), assert MinioConfig.from_env(use_dotenv=False).secure is False
("1", True),
("yes", True),
("false", False), def test_from_env_reads_create_bucket_if_missing_from_env(
("0", False),
("no", False),
],
)
def test_from_env_parses_secure(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
value: str,
expected: bool,
) -> None: ) -> None:
_set_required_minio_env(monkeypatch) _set_required_minio_env(monkeypatch)
monkeypatch.setenv("MINIO_SECURE", value) monkeypatch.setenv("MINIO_CREATE_BUCKET_IF_MISSING", "true")
config = MinioConfig.from_env(use_dotenv=False) assert MinioConfig.from_env(use_dotenv=False).create_bucket_if_missing is True
assert config.secure is expected
def test_from_env_raises_for_invalid_secure(monkeypatch: pytest.MonkeyPatch) -> None: def test_from_env_defaults_create_bucket_if_missing_to_false_when_unset(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_set_required_minio_env(monkeypatch) _set_required_minio_env(monkeypatch)
monkeypatch.setenv("MINIO_SECURE", "not-a-bool") monkeypatch.delenv("MINIO_CREATE_BUCKET_IF_MISSING", raising=False)
with pytest.raises(ValueError, match="MINIO_SECURE"): config = MinioConfig.from_env(use_dotenv=False)
MinioConfig.from_env(use_dotenv=False) assert config.create_bucket_if_missing is False
def test_from_env_raises_when_endpoint_missing( def test_from_env_raises_when_endpoint_missing(
Generated
+1 -1
View File
@@ -1056,7 +1056,7 @@ wheels = [
[[package]] [[package]]
name = "python-repositories" name = "python-repositories"
version = "1.1.0" version = "2.0.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "python-dotenv" }, { name = "python-dotenv" },