Compare commits

..
7 Commits
Author SHA1 Message Date
CI Bot 2f19fcc972 chore: release v1.0.0 [skip ci] 2026-07-08 18:25:00 +00:00
brian 3bd65895ec Merge pull request '[breaking] Clarify MinIO get() error semantics to match Redis behavior' (#33) from cursor/minio-error-semantics into main
Code Quality Pipeline / code-quality (push) Successful in 47s
Test Python Package / unit-tests (push) Successful in 1m17s
Test Python Package / coverage-report (push) Successful in 16s
Release on merge to main / release (push) Successful in 16s
Test Python Package / integration-tests (push) Successful in 1m6s
Reviewed-on: https://gitea.lille-vemmelund.dk/brian/python-repositories/pulls/33
2026-07-08 20:24:41 +02:00
Brian Bjarke JensenandCursor 5311d49fa6 Clarify MinIO get() error semantics to match Redis behavior.
PR Title Check / check-title (pull_request) Successful in 6s
Test Python Package / integration-tests (pull_request) Successful in 1m17s
Test Python Package / unit-tests (pull_request) Successful in 1m28s
Code Quality Pipeline / code-quality (pull_request) Successful in 1m36s
Test Python Package / coverage-report (pull_request) Successful in 16s
Return None only for missing objects and re-raise other S3 and network failures so callers can distinguish not-found from real errors.

Co-authored-by: Cursor <[email protected]>
2026-07-08 20:02:03 +02:00
CI Bot 703bb9521f chore: release v0.5.1 [skip ci] 2026-07-07 19:37:22 +00:00
brian 8e47ebf4c6 Merge pull request '[patch] Fix MinIO get_object response leak in MinioAdapter.get()' (#32) from fix/minio-get-object-response-cleanup into main
Release on merge to main / release (push) Successful in 14s
Test Python Package / unit-tests (push) Successful in 15s
Code Quality Pipeline / code-quality (push) Successful in 47s
Test Python Package / integration-tests (push) Successful in 54s
Test Python Package / coverage-report (push) Successful in 15s
Reviewed-on: https://gitea.lille-vemmelund.dk/brian/python-repositories/pulls/32
2026-07-07 21:37:04 +02:00
Brian Bjarke JensenandCursor 9a800bf553 Fetch live PR title in pr-title-check CI workflow.
Code Quality Pipeline / code-quality (pull_request) Successful in 24s
Test Python Package / integration-tests (pull_request) Successful in 1m18s
Test Python Package / unit-tests (pull_request) Successful in 1m24s
Test Python Package / coverage-report (pull_request) Successful in 12s
PR Title Check / check-title (pull_request) Successful in 6s
Rerunning the job now reads the current title from the Gitea API instead of the stale pull_request event payload.

Co-authored-by: Cursor <[email protected]>
2026-07-07 21:34:18 +02:00
Brian Bjarke JensenandCursor 8c34534187 Fix MinIO get_object response leak in MinioAdapter.get().
Test Python Package / integration-tests (pull_request) Successful in 27s
Code Quality Pipeline / code-quality (pull_request) Successful in 31s
Test Python Package / unit-tests (pull_request) Successful in 36s
Test Python Package / coverage-report (pull_request) Successful in 53s
PR Title Check / check-title (pull_request) Failing after 5s
Close and release the HTTP response in a finally block per SDK guidance, and add unit tests for success and read-failure cleanup.

Co-authored-by: Cursor <[email protected]>
2026-07-07 21:21:42 +02:00
8 changed files with 136 additions and 26 deletions
+7 -1
View File
@@ -16,8 +16,14 @@ jobs:
- name: Check PR title when source files change - name: Check PR title when source files change
env: env:
PR_TITLE: ${{ github.event.pull_request.title }} API_URL: ${{ vars.API_URL }}
REPO_OWNER: ${{ github.repository_owner }}
REPO_NAME: ${{ github.event.repository.name }}
PR_NUMBER: ${{ github.event.pull_request.number }}
CI_RUNNER_TOKEN: ${{ secrets.CI_RUNNER_TOKEN }}
run: | run: |
git fetch origin "${{ github.base_ref }}" git fetch origin "${{ github.base_ref }}"
PR_TITLE=$(scripts/ci/fetch-pr-title.sh)
echo "Live PR title: ${PR_TITLE}"
git diff --name-only "origin/${{ github.base_ref }}...HEAD" \ git diff --name-only "origin/${{ github.base_ref }}...HEAD" \
| scripts/ci/check-pr-title.sh "$PR_TITLE" | scripts/ci/check-pr-title.sh "$PR_TITLE"
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "python-repositories" name = "python-repositories"
version = "0.5.0" version = "1.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]" }
@@ -152,6 +152,7 @@ class MinioAdapter(ObjectRepositoryInterface, ConnectionAwareAdapter):
assert self._client is not None and self._bucket_name is not None assert self._client is not None and self._bucket_name is not None
# Get data from bucket # Get data from bucket
# N.B. bucket name is set when connecting # N.B. bucket name is set when connecting
response = None
try: try:
response = self._client.get_object( response = self._client.get_object(
bucket_name=self._bucket_name, bucket_name=self._bucket_name,
@@ -171,11 +172,12 @@ class MinioAdapter(ObjectRepositoryInterface, ConnectionAwareAdapter):
self.logger.warning( self.logger.warning(
f"Object '{object_name}' not found in bucket '{self._bucket_name}'" f"Object '{object_name}' not found in bucket '{self._bucket_name}'"
) )
else: return None
self.logger.error(repr(exc)) raise
except Exception as exc: # pylint: disable=broad-except finally:
self.logger.error(repr(exc)) if response is not None:
return None response.close()
response.release_conn()
def delete(self, object_name: str) -> None: def delete(self, object_name: str) -> None:
"""Delete an object from the Minio bucket.""" """Delete an object from the Minio bucket."""
@@ -9,7 +9,11 @@ class ObjectRepositoryInterface(ABC):
@abstractmethod @abstractmethod
def get(self, object_name: str) -> BytesIO | None: def get(self, object_name: str) -> BytesIO | None:
"""Get an object by name.""" """Get an object by name.
Returns None when the object does not exist. Raises ConnectionError when
not connected. Other backend errors propagate to the caller.
"""
... ...
@abstractmethod @abstractmethod
+16
View File
@@ -0,0 +1,16 @@
#!/usr/bin/env bash
set -euo pipefail
# Fetch the current PR title from the Gitea API.
# Requires: API_URL, REPO_OWNER, REPO_NAME, PR_NUMBER, CI_RUNNER_TOKEN
: "${API_URL:?API_URL is required}"
: "${REPO_OWNER:?REPO_OWNER is required}"
: "${REPO_NAME:?REPO_NAME is required}"
: "${PR_NUMBER:?PR_NUMBER is required}"
: "${CI_RUNNER_TOKEN:?CI_RUNNER_TOKEN is required}"
curl -sf \
"${API_URL}/repos/${REPO_OWNER}/${REPO_NAME}/pulls/${PR_NUMBER}" \
-H "Authorization: token ${CI_RUNNER_TOKEN}" \
| python3 -c 'import json, sys; print(json.load(sys.stdin)["title"])'
+9 -16
View File
@@ -218,10 +218,8 @@ def test_should_log_warning_when_getting_nonexistent_object(
) )
def test_should_log_error_when_getting_with_s3error_other_than_no_such_key( def test_should_reraise_s3error_other_than_no_such_key() -> None:
caplog: pytest.LogCaptureFixture, """Test that the MinioAdapter re-raises unhandled S3 errors."""
) -> None:
"""Test that the MinioAdapter logs an error for unhandled S3 errors."""
mock_client = MagicMock(spec=Minio) mock_client = MagicMock(spec=Minio)
mock_client.bucket_exists.return_value = True mock_client.bucket_exists.return_value = True
other_s3error = S3Error( other_s3error = S3Error(
@@ -236,25 +234,20 @@ def test_should_log_error_when_getting_with_s3error_other_than_no_such_key(
) )
mock_client.get_object.side_effect = other_s3error mock_client.get_object.side_effect = other_s3error
adapter = MinioAdapter(config=TEST_MINIO_CONFIG, client=mock_client) adapter = MinioAdapter(config=TEST_MINIO_CONFIG, client=mock_client)
with caplog.at_level("ERROR"): with pytest.raises(S3Error) as exc_info:
result = adapter.get("missing-object") adapter.get("missing-object")
assert result is None assert exc_info.value.code == "UnhandledError"
assert repr(other_s3error) in caplog.text
def test_should_log_error_when_getting_with_general_exception( def test_should_reraise_general_exception() -> None:
caplog: pytest.LogCaptureFixture, """Test that the MinioAdapter re-raises general exceptions during get."""
) -> None:
"""Test that the MinioAdapter logs an error on general exceptions during get."""
mock_client = MagicMock(spec=Minio) mock_client = MagicMock(spec=Minio)
mock_client.bucket_exists.return_value = True mock_client.bucket_exists.return_value = True
general_exception = Exception("General failure") general_exception = Exception("General failure")
mock_client.get_object.side_effect = general_exception mock_client.get_object.side_effect = general_exception
adapter = MinioAdapter(config=TEST_MINIO_CONFIG, client=mock_client) adapter = MinioAdapter(config=TEST_MINIO_CONFIG, client=mock_client)
with caplog.at_level("ERROR"): with pytest.raises(Exception, match="General failure"):
result = adapter.get("missing-object") adapter.get("missing-object")
assert result is None
assert repr(general_exception) in caplog.text
def test_should_put_data( def test_should_put_data(
+90 -1
View File
@@ -5,7 +5,8 @@ from __future__ import annotations
from unittest.mock import MagicMock from unittest.mock import MagicMock
import pytest import pytest
from minio import Minio from minio import Minio, S3Error
from urllib3.response import BaseHTTPResponse
from python_repositories.adapters.minio_adapter import MinioAdapter from python_repositories.adapters.minio_adapter import MinioAdapter
from python_repositories.interfaces import ObjectRepositoryInterface from python_repositories.interfaces import ObjectRepositoryInterface
@@ -91,3 +92,91 @@ def test_connect_disconnects_before_reconnect(
assert adapter._client is new_client assert adapter._client is new_client
new_client.list_buckets.assert_called_once() new_client.list_buckets.assert_called_once()
def test_get_closes_response_on_success() -> None:
"""get() must close and release the get_object HTTP response."""
mock_client = MagicMock(spec=Minio)
mock_client.bucket_exists.return_value = True
mock_response = MagicMock()
mock_response.read.side_effect = [b"data", b""]
mock_client.get_object.return_value = mock_response
adapter = MinioAdapter(config=TEST_MINIO_CONFIG, client=mock_client)
result = adapter.get("some-object")
assert result is not None
assert result.read() == b"data"
mock_response.close.assert_called_once()
mock_response.release_conn.assert_called_once()
def test_get_closes_response_when_read_fails() -> None:
"""get() must close and release the response even if read() raises."""
mock_client = MagicMock(spec=Minio)
mock_client.bucket_exists.return_value = True
mock_response = MagicMock()
mock_response.read.side_effect = OSError("connection reset")
mock_client.get_object.return_value = mock_response
adapter = MinioAdapter(config=TEST_MINIO_CONFIG, client=mock_client)
with pytest.raises(OSError, match="connection reset"):
adapter.get("some-object")
mock_response.close.assert_called_once()
mock_response.release_conn.assert_called_once()
def test_get_returns_none_for_no_such_key() -> None:
"""get() returns None when the object does not exist."""
mock_client = MagicMock(spec=Minio)
mock_client.bucket_exists.return_value = True
mock_client.get_object.side_effect = S3Error(
MagicMock(spec=BaseHTTPResponse),
"NoSuchKey",
"",
"",
"",
"",
bucket_name="test-bucket",
object_name="missing-object",
)
adapter = MinioAdapter(config=TEST_MINIO_CONFIG, client=mock_client)
result = adapter.get("missing-object")
assert result is None
def test_get_reraises_other_s3_errors() -> None:
"""get() re-raises S3 errors other than NoSuchKey."""
mock_client = MagicMock(spec=Minio)
mock_client.bucket_exists.return_value = True
other_s3error = S3Error(
MagicMock(spec=BaseHTTPResponse),
"AccessDenied",
"",
"",
"",
"",
bucket_name="test-bucket",
object_name="some-object",
)
mock_client.get_object.side_effect = other_s3error
adapter = MinioAdapter(config=TEST_MINIO_CONFIG, client=mock_client)
with pytest.raises(S3Error) as exc_info:
adapter.get("some-object")
assert exc_info.value.code == "AccessDenied"
def test_get_reraises_general_exception_from_get_object() -> None:
"""get() re-raises unexpected exceptions from get_object."""
mock_client = MagicMock(spec=Minio)
mock_client.bucket_exists.return_value = True
mock_client.get_object.side_effect = Exception("General failure")
adapter = MinioAdapter(config=TEST_MINIO_CONFIG, client=mock_client)
with pytest.raises(Exception, match="General failure"):
adapter.get("some-object")
Generated
+1 -1
View File
@@ -1056,7 +1056,7 @@ wheels = [
[[package]] [[package]]
name = "python-repositories" name = "python-repositories"
version = "0.5.0" version = "1.0.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "python-dotenv" }, { name = "python-dotenv" },