3 Commits

Author SHA1 Message Date
Simone Scarduzio
0064d7e74b fix: Add .delta suffix fallback for delete_object()
- delete_object() now tries with .delta suffix if file not found
- Matches the same fallback logic as download/get_object
- Fixes deletion of files uploaded as .delta when user provides original name
- Add test for delta suffix fallback in deletion

This fixes the critical bug where delete_object(Key='file.zip') would fail
with NotFoundError when the actual file was stored as 'file.zip.delta'.

Now delete_object() works consistently with get_object():
- Try with key as provided
- If NotFoundError and no .delta suffix, try with .delta appended
- Raises NotFoundError only if both attempts fail

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-06 23:05:51 +02:00
Simone Scarduzio
9c1659a1f1 fix: Handle regular S3 objects without DeltaGlider metadata
- get_object() now transparently downloads regular S3 objects
- Falls back to direct download when file_sha256 metadata is missing
- Enables DeltaGlider to work with existing S3 buckets
- Add test for downloading regular S3 files

Fixes issue where get_object() would fail with NotFoundError when
trying to download objects uploaded outside of DeltaGlider.

This allows users to:
- Browse existing S3 buckets with non-DeltaGlider objects
- Download any S3 object regardless of upload method
- Use DeltaGlider as a drop-in S3 client replacement

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-06 17:53:19 +02:00
Simone Scarduzio
34c871b0d7 fix: Make GitHub release creation non-blocking in workflows
- Add continue-on-error to GitHub release step
- Prevents workflow failure when GITHUB_TOKEN lacks permissions
- PyPI publish still succeeds even if GitHub release fails

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-06 10:24:51 +02:00
6 changed files with 93 additions and 13 deletions

View File

@@ -231,6 +231,7 @@ jobs:
- name: Create GitHub Release
uses: softprops/action-gh-release@v1
continue-on-error: true # Don't fail if GitHub release creation fails
with:
tag_name: ${{ needs.validate.outputs.tag_name }}
name: Release v${{ github.event.inputs.version }}

View File

@@ -235,6 +235,7 @@ jobs:
- name: Create GitHub Release
uses: softprops/action-gh-release@v1
continue-on-error: true # Don't fail if GitHub release creation fails
with:
tag_name: ${{ needs.validate-and-tag.outputs.tag_name }}
name: Release v${{ github.event.inputs.version }}

View File

@@ -8,6 +8,7 @@ from typing import Any
from .adapters.storage_s3 import S3StorageAdapter
from .core import DeltaService, DeltaSpace, ObjectKey
from .core.errors import NotFoundError
@dataclass
@@ -427,15 +428,23 @@ class DeltaGliderClient:
Args:
Bucket: S3 bucket name
Key: Object key
Key: Object key (can be with or without .delta suffix)
**kwargs: Additional parameters
Returns:
Response dict with deletion details
"""
# Use core service's delta-aware delete
# Try to delete with the key as provided
object_key = ObjectKey(bucket=Bucket, key=Key)
delete_result = self.service.delete(object_key)
try:
delete_result = self.service.delete(object_key)
except NotFoundError:
# Try with .delta suffix if not already present
if not Key.endswith(".delta"):
object_key = ObjectKey(bucket=Bucket, key=Key + ".delta")
delete_result = self.service.delete(object_key)
else:
raise
response = {
"DeleteMarker": False,

View File

@@ -21,7 +21,6 @@ from .errors import (
IntegrityMismatchError,
NotFoundError,
PolicyViolationWarning,
StorageIOError,
)
from .models import (
DeltaMeta,
@@ -171,10 +170,28 @@ class DeltaService:
if obj_head is None:
raise NotFoundError(f"Object not found: {object_key.key}")
# Check if this is a regular S3 object (not uploaded via DeltaGlider)
# Regular S3 objects won't have DeltaGlider metadata
if "file_sha256" not in obj_head.metadata:
raise StorageIOError(f"Missing metadata on {object_key.key}")
# This is a regular S3 object, download it directly
self.logger.info(
"Downloading regular S3 object (no DeltaGlider metadata)",
key=object_key.key,
)
self._get_direct(object_key, obj_head, out)
duration = (self.clock.now() - start_time).total_seconds()
self.logger.log_operation(
op="get",
key=object_key.key,
deltaspace=f"{object_key.bucket}",
sizes={"file": obj_head.size},
durations={"total": duration},
cache_hit=False,
)
self.metrics.timing("deltaglider.get.duration", duration)
return
# Check if this is a direct upload (non-delta)
# Check if this is a direct upload (non-delta) uploaded via DeltaGlider
if obj_head.metadata.get("compression") == "none":
# Direct download without delta processing
self._get_direct(object_key, obj_head, out)

View File

@@ -258,6 +258,26 @@ class TestBoto3Compatibility:
content = response["Body"].read()
assert content == b"Test Content"
def test_get_object_regular_s3_file(self, client):
"""Test get_object with regular S3 files (not uploaded via DeltaGlider)."""
content = b"Regular S3 File Content"
# Add as a regular S3 object WITHOUT DeltaGlider metadata
client.service.storage.objects["test-bucket/regular-file.pdf"] = {
"data": content,
"size": len(content),
"metadata": {}, # No DeltaGlider metadata
}
# Should successfully download the regular S3 object
response = client.get_object(Bucket="test-bucket", Key="regular-file.pdf")
assert "Body" in response
downloaded_content = response["Body"].read()
assert downloaded_content == content
assert response["ContentLength"] == len(content)
def test_list_objects(self, client):
"""Test list_objects with various options."""
# List all objects (default: FetchMetadata=False)
@@ -291,6 +311,24 @@ class TestBoto3Compatibility:
assert response["ResponseMetadata"]["HTTPStatusCode"] == 204
assert "test-bucket/to-delete.txt" not in client.service.storage.objects
def test_delete_object_with_delta_suffix_fallback(self, client):
"""Test delete_object with automatic .delta suffix fallback."""
# Add object with .delta suffix (as DeltaGlider stores it)
client.service.storage.objects["test-bucket/file.zip.delta"] = {
"size": 100,
"metadata": {
"original_name": "file.zip",
"compression": "delta",
},
}
# Delete using original name (without .delta)
response = client.delete_object(Bucket="test-bucket", Key="file.zip")
assert response["ResponseMetadata"]["HTTPStatusCode"] == 204
assert response["DeltaGliderInfo"]["Deleted"] is True
assert "test-bucket/file.zip.delta" not in client.service.storage.objects
def test_delete_objects(self, client):
"""Test batch delete."""
# Add objects

View File

@@ -147,22 +147,36 @@ class TestDeltaServiceGet:
service.get(delta_key, temp_dir / "output.zip")
def test_get_missing_metadata(self, service, mock_storage, temp_dir):
"""Test get with missing metadata."""
"""Test get with missing metadata (regular S3 object)."""
# Setup
delta_key = ObjectKey(bucket="test-bucket", key="test/file.zip.delta")
# Create test content
test_content = b"regular S3 file content"
# Mock a regular S3 object without DeltaGlider metadata
mock_storage.head.return_value = ObjectHead(
key="test/file.zip.delta",
size=100,
size=len(test_content),
etag="abc",
last_modified=None,
metadata={}, # Missing required metadata
metadata={}, # Missing DeltaGlider metadata - this is a regular S3 object
)
# Execute and verify
from deltaglider.core.errors import StorageIOError
# Mock the storage.get to return the content
from unittest.mock import MagicMock
with pytest.raises(StorageIOError):
service.get(delta_key, temp_dir / "output.zip")
mock_stream = MagicMock()
mock_stream.read.side_effect = [test_content, b""] # Return content then EOF
mock_storage.get.return_value = mock_stream
# Execute - should successfully download regular S3 object
output_path = temp_dir / "output.zip"
service.get(delta_key, output_path)
# Verify - file should be downloaded
assert output_path.exists()
assert output_path.read_bytes() == test_content
class TestDeltaServiceVerify: