diff --git a/CHANGELOG.md b/CHANGELOG.md index 91154cf..94a47ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,3 +2,6 @@ ## v0.0.1 * Initial release. + +## v0.1.0 + * Expose Pre-Signed URL handling methods via StorageClient API diff --git a/setup.cfg b/setup.cfg index a815cef..5f8d687 100644 --- a/setup.cfg +++ b/setup.cfg @@ -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 diff --git a/src/clevercloud_storage_framework/client.py b/src/clevercloud_storage_framework/client.py index 05f27b5..a8b8ad4 100644 --- a/src/clevercloud_storage_framework/client.py +++ b/src/clevercloud_storage_framework/client.py @@ -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) diff --git a/src/clevercloud_storage_framework/exceptions.py b/src/clevercloud_storage_framework/exceptions.py index 54ee1b2..1f1a692 100644 --- a/src/clevercloud_storage_framework/exceptions.py +++ b/src/clevercloud_storage_framework/exceptions.py @@ -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) diff --git a/src/clevercloud_storage_framework/interface.py b/src/clevercloud_storage_framework/interface.py index fc4b03e..8d410aa 100644 --- a/src/clevercloud_storage_framework/interface.py +++ b/src/clevercloud_storage_framework/interface.py @@ -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: """ diff --git a/src/clevercloud_storage_framework/providers/efs_provider.py b/src/clevercloud_storage_framework/providers/efs_provider.py index 26b952d..a92c224 100644 --- a/src/clevercloud_storage_framework/providers/efs_provider.py +++ b/src/clevercloud_storage_framework/providers/efs_provider.py @@ -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. diff --git a/src/clevercloud_storage_framework/providers/glacier_provider.py b/src/clevercloud_storage_framework/providers/glacier_provider.py index 4ce4032..2f4bdfd 100644 --- a/src/clevercloud_storage_framework/providers/glacier_provider.py +++ b/src/clevercloud_storage_framework/providers/glacier_provider.py @@ -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. diff --git a/src/clevercloud_storage_framework/providers/local_provider.py b/src/clevercloud_storage_framework/providers/local_provider.py index 87e5d16..95c98d1 100644 --- a/src/clevercloud_storage_framework/providers/local_provider.py +++ b/src/clevercloud_storage_framework/providers/local_provider.py @@ -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. diff --git a/src/clevercloud_storage_framework/providers/s3_provider.py b/src/clevercloud_storage_framework/providers/s3_provider.py index 25d964e..cb0c657 100644 --- a/src/clevercloud_storage_framework/providers/s3_provider.py +++ b/src/clevercloud_storage_framework/providers/s3_provider.py @@ -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: diff --git a/tests/test_client.py b/tests/test_client.py index 7cf27fe..9d5aa8e 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -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) \ No newline at end of file diff --git a/tests/test_efs_provider.py b/tests/test_efs_provider.py index a26db19..a339aae 100644 --- a/tests/test_efs_provider.py +++ b/tests/test_efs_provider.py @@ -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 diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index 8cbdbc7..745bb26 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -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" diff --git a/tests/test_glacier_provider.py b/tests/test_glacier_provider.py index 89819f1..014b9b8 100644 --- a/tests/test_glacier_provider.py +++ b/tests/test_glacier_provider.py @@ -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 diff --git a/tests/test_local_provider.py b/tests/test_local_provider.py index 5b45e2b..7a30431 100644 --- a/tests/test_local_provider.py +++ b/tests/test_local_provider.py @@ -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 diff --git a/tests/test_s3_provider.py b/tests/test_s3_provider.py index bbe11b2..579830f 100644 --- a/tests/test_s3_provider.py +++ b/tests/test_s3_provider.py @@ -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" \ No newline at end of file