feat: Expose Pre-Signed URL support methods via StorageClient API #4

Merged
CoreRasurae merged 1 commits from feat/expose_pre_signed_url_methods-#2 into master 2025-09-24 10:04:31 +00:00
15 changed files with 304 additions and 22 deletions
+3
View File
@@ -2,3 +2,6 @@
## v0.0.1
* Initial release.
## v0.1.0
* Expose Pre-Signed URL handling methods via StorageClient API
+3 -3
View File
@@ -1,6 +1,6 @@
[metadata]
name = clevercloud_storage_framework
version = 0.0.1
version = 0.1.0
description = A unified framework for interacting with various AWS storage services
long_description = file: README.md
long_description_content_type = text/markdown
@@ -57,12 +57,12 @@ dev =
mypy>=0.812
[flake8]
max-line-length = 100
max-line-length = 120
exclude = .git,__pycache__,build,dist
[isort]
profile = black
line_length = 100
line_length = 120
[mypy]
python_version = 3.8
@@ -333,3 +333,26 @@ class StorageClient:
str: The name of the storage provider
"""
return self.provider.get_provider_name()
def supports_presigned_urls(self) -> bool:
""" """
return self.provider.supports_presigned_urls()
def generate_presigned_url(self, path: str, expiration: int = 3600) -> str:
"""
Generate a pre-signed URL for the given S3 path.
Args:
path (str): The S3 path (s3://bucket/key) or (s3://bucket/key?SAS_TOKEN)
expiration (int, optional): The time in seconds for the URL to remain valid. Defaults to 3600 (1 hour).
Returns:
str: The pre-signed URL
Raises:
InvalidPathError: If the path format is invalid
ResourceNotFoundError: If the file does not exist
StorageError: For other S3-related errors
UnsupportedError: If the provider does not handle pre-signed URLs
"""
return self.provider.generate_presigned_url(path, expiration)
@@ -188,3 +188,25 @@ class ConfigurationPropertyError(StorageError):
message = "Unsupported configuration property"
super().__init__(message, provider)
class UnsupportedError(StorageError):
"""
Exception raised when a provider does not support a given feature.
This exception is raised When a provider does not a given feature. This can happen
when a provider cannot create Pre-Signed URLs.
"""
def __init__(self, message=None, provider=None):
"""
Initialize a new UnsupportedError.
Args:
message (str, optional): Human-readable error description
provider (str, optional): The storage provider where the error occurred
"""
if message is None:
message = "Unsupported feature requested"
super().__init__(message, provider)
@@ -233,6 +233,36 @@ class StorageProviderInterface(ABC):
"""
pass
@abstractmethod
def supports_presigned_urls(self) -> bool:
"""
Checks if a given provider can handle pre-signed URLs
Returns:
bool: True, if the provider supports pre-signed URLs
"""
pass
@abstractmethod
def generate_presigned_url(self, path: str, expiration: int = 3600) -> str:
"""
Generate a pre-signed URL for the given S3 path.
Args:
path (str): The S3 path (s3://bucket/key) or (s3://bucket/key?SAS_TOKEN)
expiration (int, optional): The time in seconds for the URL to remain valid. Defaults to 3600 (1 hour).
Returns:
str: The pre-signed URL
Raises:
InvalidPathError: If the path format is invalid
ResourceNotFoundError: If the file does not exist
StorageError: For other S3-related errors
UnsupportedError: If the provider does not handle pre-signed URLs
"""
pass
@abstractmethod
def get_provider_name(self) -> str:
"""
@@ -21,6 +21,7 @@ from clevercloud_storage_framework.exceptions import (
ResourceNotFoundError,
StorageError,
StoragePermissionError,
UnsupportedError,
)
from clevercloud_storage_framework.interface import StorageProviderInterface
from clevercloud_storage_framework.utils.stream import ChunkedReader
@@ -909,6 +910,14 @@ class EFSStorageProvider(StorageProviderInterface):
self.logger.error(f"EFS delete_directory error: {str(e)}")
raise StorageError(f"Failed to delete directory: {str(e)}", provider="efs")
def supports_presigned_urls(self) -> bool:
return False
def generate_presigned_url(self, path: str, expiration: int = 3600) -> str:
raise UnsupportedError(
"Pre-signed URLs are not available for this provider", self
)
def get_provider_name(self) -> str:
"""
Get the name of the storage provider.
@@ -15,6 +15,7 @@ from ..exceptions import (
OperationNotSupportedError,
ResourceNotFoundError,
StorageError,
UnsupportedError,
)
from ..interface import StorageProviderInterface
from ..utils.stream import ChunkedReader
@@ -96,6 +97,14 @@ class GlacierStorageProvider(StorageProviderInterface):
return vault_name, archive_id
def supports_presigned_urls(self) -> bool:
return False
def generate_presigned_url(self, path: str, expiration: int = 3600) -> str:
raise UnsupportedError(
"Pre-signed URLs are not available for this provider", self
)
def supports_path(self, path: str) -> bool:
"""
Check if a path is supported by this provider.
@@ -15,6 +15,7 @@ from clevercloud_storage_framework.exceptions import (
InvalidPathError,
ResourceNotFoundError,
StorageError,
UnsupportedError,
)
from clevercloud_storage_framework.interface import StorageProviderInterface
@@ -627,6 +628,14 @@ class LocalStorageProvider(StorageProviderInterface):
f"Failed to delete directory: {str(e)}", provider="local"
)
def supports_presigned_urls(self) -> bool:
return False
def generate_presigned_url(self, path: str, expiration: int = 3600) -> str:
raise UnsupportedError(
"Pre-signed URLs are not available for this provider", self
)
def get_provider_name(self) -> str:
"""
Get the name of the storage provider.
@@ -428,22 +428,10 @@ class S3StorageProvider(StorageProviderInterface):
if "body" in locals():
body.close()
def supports_presigned_urls(self) -> bool:
return True
def generate_presigned_url(self, path: str, expiration: int = 3600) -> str:
"""
Generate a pre-signed URL for the given S3 path.
Args:
path (str): The S3 path (s3://bucket/key) or (s3://bucket/key?SAS_TOKEN)
expiration (int, optional): The time in seconds for the URL to remain valid. Defaults to 3600 (1 hour).
Returns:
str: The pre-signed URL
Raises:
InvalidPathError: If the path format is invalid
ResourceNotFoundError: If the file does not exist
StorageError: For other S3-related errors
"""
bucket, key = self.parse_path(path)
try:
+42
View File
@@ -355,3 +355,45 @@ class TestStorageClient:
# Verify that the provider's copy_file method was called
mock_provider.copy_file.assert_called_once_with('/source/path', '/dest/path')
def test_supports_presigned_urls(self, client, mock_provider):
"""Test checking if the provider supports pre-signed URLs."""
# Set up the mock provider
mock_provider.supports_presigned_urls.return_value = True
# Call the method
result = client.supports_presigned_urls()
# Check the result
assert result is True
# Check that the provider method was called
mock_provider.supports_presigned_urls.assert_called_once()
def test_generate_presigned_url(self, client, mock_provider):
"""Test generating a pre-signed URL."""
# Set up the mock provider
mock_provider.generate_presigned_url.return_value = 'https://example.com/presigned-url'
# Call the method
url = client.generate_presigned_url('/path/to/file.txt', expiration=1800)
# Check the result
assert url == 'https://example.com/presigned-url'
# Check that the provider method was called
mock_provider.generate_presigned_url.assert_called_once_with('/path/to/file.txt', 1800)
def test_generate_presigned_url_default_expiration(self, client, mock_provider):
"""Test generating a pre-signed URL with default expiration."""
# Set up the mock provider
mock_provider.generate_presigned_url.return_value = 'https://example.com/presigned-url'
# Call the method without expiration parameter
url = client.generate_presigned_url('/path/to/file.txt')
# Check the result
assert url == 'https://example.com/presigned-url'
# Check that the provider method was called with default expiration
mock_provider.generate_presigned_url.assert_called_once_with('/path/to/file.txt', 3600)
+14 -1
View File
@@ -12,7 +12,8 @@ from unittest.mock import patch, MagicMock
from clevercloud_storage_framework.providers import EFSStorageProvider
from clevercloud_storage_framework.exceptions import (
StorageError, InvalidPathError, ResourceNotFoundError, StoragePermissionError
StorageError, InvalidPathError, ResourceNotFoundError, StoragePermissionError,
UnsupportedError
)
@@ -175,3 +176,15 @@ class TestEFSStorageProvider:
# Skip this part of the test for now as it's causing issues
# The actual implementation should still be correct, but the test is difficult to mock properly
# TODO: Revisit this test case when we have a better way to mock the filesystem
def test_supports_presigned_urls(self, efs_provider):
"""Test that EFS provider does not support pre-signed URLs."""
assert efs_provider.supports_presigned_urls() is False
def test_generate_presigned_url(self, efs_provider):
"""Test that EFS provider raises UnsupportedError for pre-signed URLs."""
with pytest.raises(UnsupportedError) as exc_info:
efs_provider.generate_presigned_url('efs://fs-12345/path/to/file')
assert "Pre-signed URLs are not available for this provider" in str(exc_info.value)
assert exc_info.value.provider == efs_provider
+19 -1
View File
@@ -9,7 +9,8 @@ import pytest
from clevercloud_storage_framework.exceptions import (
StorageError, InvalidPathError, OperationNotSupportedError,
AuthenticationError, ResourceNotFoundError, StoragePermissionError
AuthenticationError, ResourceNotFoundError, StoragePermissionError,
UnsupportedError
)
@@ -109,3 +110,20 @@ class TestExceptions:
assert "Custom message only" in str(error)
assert error.resource is None
assert error.provider is None
def test_unsupported_error(self):
"""Test the UnsupportedError exception."""
# Create an exception with default message
error = UnsupportedError()
assert "Unsupported feature requested" in str(error)
assert error.provider is None
# Create an exception with custom message
error = UnsupportedError("Pre-signed URLs not supported")
assert "Pre-signed URLs not supported" in str(error)
assert error.provider is None
# Create an exception with custom message and provider
error = UnsupportedError("Pre-signed URLs not supported", "efs")
assert "Pre-signed URLs not supported" in str(error)
assert error.provider == "efs"
+13 -1
View File
@@ -15,7 +15,7 @@ from unittest.mock import patch, MagicMock
from clevercloud_storage_framework.providers import GlacierStorageProvider
from clevercloud_storage_framework.exceptions import (
StorageError, InvalidPathError, ResourceNotFoundError, StoragePermissionError,
OperationNotSupportedError
OperationNotSupportedError, UnsupportedError
)
@@ -341,3 +341,15 @@ class TestGlacierStorageProvider:
vaultName=mock_glacier_vault,
jobId='test-job-id'
)
def test_supports_presigned_urls(self, glacier_provider):
"""Test that Glacier provider does not support pre-signed URLs."""
assert glacier_provider.supports_presigned_urls() is False
def test_generate_presigned_url(self, glacier_provider):
"""Test that Glacier provider raises UnsupportedError for pre-signed URLs."""
with pytest.raises(UnsupportedError) as exc_info:
glacier_provider.generate_presigned_url('glacier://test-vault/archive-id')
assert "Pre-signed URLs are not available for this provider" in str(exc_info.value)
assert exc_info.value.provider == glacier_provider
+14 -1
View File
@@ -13,7 +13,8 @@ from pathlib import Path
from clevercloud_storage_framework.providers.local_provider import LocalStorageProvider
from clevercloud_storage_framework.exceptions import (
StorageError, InvalidPathError, ResourceNotFoundError, StoragePermissionError
StorageError, InvalidPathError, ResourceNotFoundError, StoragePermissionError,
UnsupportedError
)
@@ -328,3 +329,15 @@ class TestLocalStorageProvider:
f.write('test content')
with pytest.raises(ResourceNotFoundError):
local_provider.delete_directory(file_path)
def test_supports_presigned_urls(self, local_provider):
"""Test that Local provider does not support pre-signed URLs."""
assert local_provider.supports_presigned_urls() is False
def test_generate_presigned_url(self, local_provider):
"""Test that Local provider raises UnsupportedError for pre-signed URLs."""
with pytest.raises(UnsupportedError) as exc_info:
local_provider.generate_presigned_url('/path/to/file.txt')
assert "Pre-signed URLs are not available for this provider" in str(exc_info.value)
assert exc_info.value.provider == local_provider
+91
View File
@@ -523,3 +523,94 @@ class TestS3StorageProvider:
# Read with offset and length
content = b''.join(s3_provider.read_file(f's3://{mock_s3_bucket}/test-file.txt', offset=5, length=4))
assert content == b'cont'
def test_supports_presigned_urls(self, s3_provider):
"""Test that S3 provider supports pre-signed URLs."""
assert s3_provider.supports_presigned_urls() is True
@mock_s3
def test_generate_presigned_url_success(self, s3_provider, mock_s3_bucket):
"""Test successful generation of pre-signed URL."""
# Mock the client's generate_presigned_url method
mock_url = 'https://test-bucket.s3.amazonaws.com/test-key?AWSAccessKeyId=test&Signature=test&Expires=1234567890'
s3_provider.client.generate_presigned_url.return_value = mock_url
# Generate pre-signed URL
url = s3_provider.generate_presigned_url(f's3://{mock_s3_bucket}/test-key', expiration=1800)
# Verify the URL was generated correctly
assert url == mock_url
# Verify the client method was called with correct parameters
s3_provider.client.generate_presigned_url.assert_called_once_with(
'get_object',
Params={'Bucket': mock_s3_bucket, 'Key': 'test-key'},
ExpiresIn=1800
)
@mock_s3
def test_generate_presigned_url_default_expiration(self, s3_provider, mock_s3_bucket):
"""Test generation of pre-signed URL with default expiration."""
# Mock the client's generate_presigned_url method
mock_url = 'https://test-bucket.s3.amazonaws.com/test-key?AWSAccessKeyId=test&Signature=test&Expires=1234567890'
s3_provider.client.generate_presigned_url.return_value = mock_url
# Generate pre-signed URL without specifying expiration
url = s3_provider.generate_presigned_url(f's3://{mock_s3_bucket}/test-key')
# Verify the URL was generated correctly
assert url == mock_url
# Verify the client method was called with default expiration (3600 seconds)
s3_provider.client.generate_presigned_url.assert_called_once_with(
'get_object',
Params={'Bucket': mock_s3_bucket, 'Key': 'test-key'},
ExpiresIn=3600
)
@mock_s3
def test_generate_presigned_url_invalid_path(self, s3_provider, mock_s3_bucket):
"""Test generation of pre-signed URL with invalid path."""
from clevercloud_storage_framework.exceptions import InvalidPathError
# Test with invalid path format
with pytest.raises(InvalidPathError):
s3_provider.generate_presigned_url('invalid-path')
@mock_s3
def test_generate_presigned_url_file_not_found(self, s3_provider, mock_s3_bucket):
"""Test generation of pre-signed URL for non-existent file."""
# Note: The current S3 provider implementation doesn't check if the file exists
# before generating a pre-signed URL, so this test verifies that behavior
# Mock the client's generate_presigned_url method to return a URL
mock_url = 'https://test-bucket.s3.amazonaws.com/nonexistent-key?AWSAccessKeyId=test&Signature=test&Expires=1234567890'
s3_provider.client.generate_presigned_url.return_value = mock_url
# Generate pre-signed URL for non-existent file (should succeed)
url = s3_provider.generate_presigned_url(f's3://{mock_s3_bucket}/nonexistent-key')
# Verify the URL was generated correctly
assert url == mock_url
# Verify the client method was called with correct parameters
s3_provider.client.generate_presigned_url.assert_called_once_with(
'get_object',
Params={'Bucket': mock_s3_bucket, 'Key': 'nonexistent-key'},
ExpiresIn=3600
)
@mock_s3
def test_generate_presigned_url_client_error(self, s3_provider, mock_s3_bucket):
"""Test generation of pre-signed URL with client error."""
from clevercloud_storage_framework.exceptions import StorageError
# Mock generate_presigned_url to raise ClientError
error_response = {'Error': {'Code': 'AccessDenied', 'Message': 'Access Denied'}}
s3_provider.client.generate_presigned_url.side_effect = ClientError(error_response, 'GeneratePresignedUrl')
# Test with client error
with pytest.raises(StorageError) as exc_info:
s3_provider.generate_presigned_url(f's3://{mock_s3_bucket}/test-key')
assert "Access Denied" in str(exc_info.value)
assert exc_info.value.provider == "s3"