diff --git a/pyproject.toml b/pyproject.toml index c6b0bcde..b2a4aff3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,8 @@ authors = [ requires-python = ">=3.11" dependencies = [ "minio~=7.2.16", + "azure-storage-blob>=12.20.0", + "google-cloud-storage>=2.18.0", "setuptools>=83.0", "requests>=2.33.0", "requests-oauthlib~=2.0.0", @@ -76,6 +78,8 @@ dev = [ "django>=4.0", "flask>=3.0", "a2a-sdk>=0.2.0", + "azure-storage-blob>=12.20.0", + "google-cloud-storage>=2.18.0", "langchain-core>=1.2.7", "langgraph>=0.2.0", "langchain-community>=0.3.0", diff --git a/src/sap_cloud_sdk/objectstore/__init__.py b/src/sap_cloud_sdk/objectstore/__init__.py index 770c1ad8..49e4f53f 100644 --- a/src/sap_cloud_sdk/objectstore/__init__.py +++ b/src/sap_cloud_sdk/objectstore/__init__.py @@ -1,6 +1,8 @@ """SAP Cloud SDK for Python - Object Store module -The create_client() uses secret resolver to load credentials from mounts/env vars +``create_client()`` auto-detects the cloud provider from the service binding and +returns a client implementing the ``ObjectStoreClient`` protocol. Supported +providers: S3/MinIO, Azure Blob Storage, Google Cloud Storage. Usage: from sap_cloud_sdk.objectstore import create_client @@ -8,69 +10,38 @@ client = create_client("object-store-1") """ -from typing import Optional - +from sap_cloud_sdk.objectstore._factory import create_client +from sap_cloud_sdk.objectstore._models import ObjectMetadata +from sap_cloud_sdk.objectstore._protocol import ObjectReader, ObjectStoreClient +from sap_cloud_sdk.objectstore.config import ( + AzureConfig, + GcsConfig, + S3Config, +) from sap_cloud_sdk.objectstore.exceptions import ( - ObjectStoreError, ClientCreationError, - ObjectOperationError, - ObjectNotFoundError, + ConfigError, ListObjectsError, + ObjectNotFoundError, + ObjectOperationError, + ObjectStoreError, ) -from sap_cloud_sdk.objectstore._models import ObjectStoreBindingData, ObjectMetadata -from sap_cloud_sdk.objectstore._s3 import ObjectStoreClient -from sap_cloud_sdk.core.secret_resolver import read_from_mount_and_fallback_to_env_var - - -def create_client( - instance: str, - *, - config: Optional[ObjectStoreBindingData] = None, - disable_ssl: bool = False, -) -> ObjectStoreClient: - """Creates an ObjectStoreClient with automatic local/cloud detection. - Uses secret resolver to load credentials from mounted secrets or environment variables - - Args: - instance: Instance name for cloud mode secret resolution. Must be a non-empty string. - config: Optional explicit configuration. If provided, auto-detection is skipped - and this configuration is used directly. - disable_ssl: Whether to disable SSL/TLS connections. Defaults to False. - - Returns: - ObjectStoreClient: Configured client ready for object storage operations. - - Raises: - ValueError: If instance parameter is empty or None. - ClientCreationError: If client creation fails due to configuration or connection issues. - """ - if not instance or not instance.strip(): - raise ValueError("instance parameter must be a non-empty string") - - # Cloud mode: with explicit configuration - if config is not None: - return ObjectStoreClient(config, disable_ssl=disable_ssl) - - # Cloud mode: use secret resolver to load configuration - config = ObjectStoreBindingData() - read_from_mount_and_fallback_to_env_var( - base_volume_mount="/etc/secrets/appfnd", - base_var_name="CLOUD_SDK_CFG", - module="objectstore", - instance=instance, - target=config, - ) - return ObjectStoreClient(config, disable_ssl=disable_ssl) - __all__ = [ - # Public user-facing types + # Protocol (usable as a type annotation) + "ObjectStoreClient", + "ObjectReader", + # Config types (pass to create_client() to bypass auto-detection) + "S3Config", + "AzureConfig", + "GcsConfig", + # Metadata model "ObjectMetadata", - "ObjectStoreBindingData", # Factory function "create_client", # Exceptions "ObjectStoreError", + "ConfigError", "ClientCreationError", "ObjectOperationError", "ObjectNotFoundError", diff --git a/src/sap_cloud_sdk/objectstore/_azure.py b/src/sap_cloud_sdk/objectstore/_azure.py new file mode 100644 index 00000000..7f07f22e --- /dev/null +++ b/src/sap_cloud_sdk/objectstore/_azure.py @@ -0,0 +1,356 @@ +"""Azure Blob Storage backend implementation for object store operations.""" + +import os +from types import TracebackType +from typing import TYPE_CHECKING, BinaryIO, List, NoReturn, Self + +from sap_cloud_sdk.core.telemetry import Module, Operation, record_metrics +from sap_cloud_sdk.objectstore.config import AzureConfig +from sap_cloud_sdk.objectstore._models import ObjectMetadata +from sap_cloud_sdk.objectstore._protocol import ObjectReader +from sap_cloud_sdk.objectstore._validation import ( + validate_object_name, + validate_prefix, + validate_put_from_bytes, + validate_put_from_file, + validate_put_object, +) +from sap_cloud_sdk.objectstore.exceptions import ( + ClientCreationError, + ListObjectsError, + ObjectNotFoundError, + ObjectOperationError, +) + +if TYPE_CHECKING: + from azure.storage.blob import StorageStreamDownloader + + +class _AzureObjectReader: + """Add a managed-reader lifecycle to an Azure blob downloader.""" + + def __init__(self, downloader: "StorageStreamDownloader[bytes]") -> None: + self._downloader: StorageStreamDownloader[bytes] | None = downloader + + def _require_open(self) -> "StorageStreamDownloader[bytes]": + if self._downloader is None: + raise ValueError("I/O operation on closed object reader") + return self._downloader + + def read(self, size: int = -1, /) -> bytes: + return self._require_open().read(size) + + def close(self) -> None: + # StorageStreamDownloader has no close operation. Dropping the reference + # ends the adapter's logical lifetime and releases its buffered state. + self._downloader = None + + def __enter__(self) -> Self: + self._require_open() + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + /, + ) -> None: + self.close() + + +class AzureClient: + """Azure Blob Storage object storage client. + + Provides the standard 8-method object store interface backed by + Azure Blob Storage. Obtain an instance via ``create_client()``. + """ + + def __init__(self, config: AzureConfig) -> None: + """Initialise the Azure object storage client. + + Args: + config: Azure Blob Storage client configuration. + + Raises: + ClientCreationError: If client initialisation fails. + """ + try: + self._container = self._create_container_client(config) + except ClientCreationError: + raise + except Exception as e: + raise ClientCreationError(f"Failed to initialise AzureClient: {e}") from e + + def _create_container_client(self, cfg: AzureConfig): + """Build an Azure ContainerClient from binding data. + + Uses the container URI directly (which already includes the container name) + to construct a ContainerClient — avoids double-appending the container path. + """ + try: + from azure.storage.blob import ContainerClient + + return ContainerClient.from_container_url( + cfg.container_uri, credential=cfg.sas_token + ) + except ImportError as e: + raise ClientCreationError( + "azure-storage-blob is required for Azure Object Store support. " + "Install it with: pip install 'sap-cloud-sdk[azure]'" + ) from e + except Exception as e: + raise ClientCreationError( + f"Failed to create Azure ContainerClient: {e}" + ) from e + + def _blob_client(self, name: str): + """Return a BlobClient for the named blob in this container.""" + return self._container.get_blob_client(name) + + @record_metrics(Module.OBJECTSTORE, Operation.OBJECTSTORE_PUT_OBJECT_FROM_BYTES) + def put_object_from_bytes(self, name: str, data: bytes, content_type: str) -> None: + """Upload an object from bytes. + + Args: + name: Name/key of the object to upload. + data: Byte data to upload. + content_type: MIME type of the object. + + Raises: + ValueError: If any parameter is invalid. + ObjectOperationError: If the upload fails. + """ + validate_put_from_bytes(name, data, content_type) + + try: + from azure.storage.blob import ContentSettings + + self._blob_client(name).upload_blob( + data, + overwrite=True, + content_settings=ContentSettings(content_type=content_type), + ) + except Exception as e: + raise ObjectOperationError(f"Failed to upload object '{name}': {e}") from e + + @record_metrics(Module.OBJECTSTORE, Operation.OBJECTSTORE_PUT_OBJECT) + def put_object( + self, name: str, stream: BinaryIO, size: int, content_type: str + ) -> None: + """Upload an object from a stream. + + Args: + name: Name/key of the object to upload. + stream: Binary stream containing the object data. + size: Size of the object in bytes. + content_type: MIME type of the object. + + Raises: + ValueError: If any parameter is invalid. + ObjectOperationError: If the upload fails. + """ + validate_put_object(name, stream, size, content_type) + + try: + from azure.storage.blob import ContentSettings + + self._blob_client(name).upload_blob( + stream, + length=size, + overwrite=True, + content_settings=ContentSettings(content_type=content_type), + ) + except Exception as e: + raise ObjectOperationError(f"Failed to upload object '{name}': {e}") from e + + @record_metrics(Module.OBJECTSTORE, Operation.OBJECTSTORE_PUT_OBJECT_FROM_FILE) + def put_object_from_file( + self, name: str, file_path: str, content_type: str + ) -> None: + """Upload an object from a local file. + + Args: + name: Name/key of the object to upload. + file_path: Path to the local file to upload. + content_type: MIME type of the object. + + Raises: + ValueError: If any parameter is invalid. + ObjectOperationError: If the upload fails. + """ + validate_put_from_file(name, file_path, content_type) + + try: + from azure.storage.blob import ContentSettings + + if not os.path.isfile(file_path): + raise ObjectOperationError(f"File not found: {file_path}") + + with open(file_path, "rb") as f: + self._blob_client(name).upload_blob( + f, + overwrite=True, + content_settings=ContentSettings(content_type=content_type), + ) + except ObjectOperationError: + raise + except Exception as e: + raise ObjectOperationError(f"Failed to upload object '{name}': {e}") from e + + @record_metrics(Module.OBJECTSTORE, Operation.OBJECTSTORE_GET_OBJECT) + def get_object(self, name: str) -> ObjectReader: + """Download an object as a stream. + + Args: + name: Name/key of the object to download. + + Returns: + A readable binary stream of the object data. + + Raises: + ValueError: If name is invalid. + ObjectNotFoundError: If the object does not exist. + ObjectOperationError: If the download fails. + """ + validate_object_name(name) + + try: + downloader = self._blob_client(name).download_blob() + return _AzureObjectReader(downloader) + except Exception as e: + self._map_azure_error(e, name, "download") + + @record_metrics(Module.OBJECTSTORE, Operation.OBJECTSTORE_DELETE_OBJECT) + def delete_object(self, name: str) -> None: + """Delete an object (idempotent — no error if already absent). + + Args: + name: Name/key of the object to delete. + + Raises: + ValueError: If name is invalid. + ObjectOperationError: If the deletion fails. + """ + validate_object_name(name) + + try: + self._blob_client(name).delete_blob() + except Exception as e: + try: + from azure.core.exceptions import ResourceNotFoundError + + if isinstance(e, ResourceNotFoundError): + return # idempotent + except ImportError: + pass + raise ObjectOperationError(f"Failed to delete object '{name}': {e}") from e + + @record_metrics(Module.OBJECTSTORE, Operation.OBJECTSTORE_LIST_OBJECTS) + def list_objects(self, prefix: str) -> List[ObjectMetadata]: + """List objects with a given prefix. + + Args: + prefix: Prefix to filter objects by name. + + Returns: + List of object metadata. + + Raises: + ValueError: If prefix is invalid. + ListObjectsError: If listing fails. + """ + validate_prefix(prefix) + + try: + result = [] + for blob in self._container.list_blobs(name_starts_with=prefix): + result.append( + ObjectMetadata( + key=blob.name, + last_modified=blob.last_modified, + etag=(blob.etag or "").strip('"'), + size=blob.size or 0, + storage_class=str(blob.blob_tier) if blob.blob_tier else None, + owner=None, + ) + ) + return result + except Exception as e: + raise ListObjectsError( + f"Failed to list objects with prefix '{prefix}': {e}" + ) from e + + @record_metrics(Module.OBJECTSTORE, Operation.OBJECTSTORE_HEAD_OBJECT) + def head_object(self, name: str) -> ObjectMetadata: + """Get metadata for an object without downloading it. + + Args: + name: Name/key of the object. + + Returns: + Object metadata. + + Raises: + ValueError: If name is invalid. + ObjectNotFoundError: If the object does not exist. + ObjectOperationError: If the operation fails. + """ + validate_object_name(name) + + try: + props = self._blob_client(name).get_blob_properties() + return ObjectMetadata( + key=name, + last_modified=props.last_modified, + etag=(props.etag or "").strip('"'), + size=props.size or 0, + storage_class=str(props.blob_tier) if props.blob_tier else None, + owner=None, + ) + except Exception as e: + self._map_azure_error(e, name, "head") + + @record_metrics(Module.OBJECTSTORE, Operation.OBJECTSTORE_OBJECT_EXISTS) + def object_exists(self, name: str) -> bool: + """Check if an object exists. + + Args: + name: Name/key of the object to check. + + Returns: + True if the object exists, False otherwise. + + Raises: + ValueError: If name is invalid. + ObjectOperationError: If the check fails. + """ + validate_object_name(name) + + try: + self.head_object(name) + return True + except ObjectNotFoundError: + return False + except Exception as e: + raise ObjectOperationError( + f"Failed to check if object '{name}' exists: {e}" + ) from e + + def _map_azure_error(self, exc: Exception, name: str, operation: str) -> NoReturn: + """Map Azure SDK exceptions to objectstore exceptions and re-raise.""" + try: + from azure.core.exceptions import ( + HttpResponseError, + ResourceNotFoundError, + ) + + if isinstance(exc, ResourceNotFoundError) or ( + isinstance(exc, HttpResponseError) and exc.status_code == 404 + ): + raise ObjectNotFoundError(f"Object '{name}' not found") from exc + except ImportError: + pass + raise ObjectOperationError( + f"Failed to {operation} object '{name}': {exc}" + ) from exc diff --git a/src/sap_cloud_sdk/objectstore/_detect.py b/src/sap_cloud_sdk/objectstore/_detect.py new file mode 100644 index 00000000..40e9a198 --- /dev/null +++ b/src/sap_cloud_sdk/objectstore/_detect.py @@ -0,0 +1,101 @@ +"""Provider auto-detection for the objectstore module. + +Enumerates the binding keys present in the configured secret mount or +environment variables to determine which cloud provider is backing a given +objectstore instance. +""" + +import os + +from sap_cloud_sdk.core.secret_resolver import resolve_base_mount +from sap_cloud_sdk.objectstore._models import ObjectStoreProvider + +_DISCRIMINATORS: dict[ObjectStoreProvider, set[str]] = { + ObjectStoreProvider.AZURE: {"container_uri", "sas_token", "container_name"}, + ObjectStoreProvider.GCS: { + "base64EncodedPrivateKeyData", + "projectId", + }, + ObjectStoreProvider.S3: {"access_key_id", "secret_access_key", "host"}, +} + +_DEFAULT_BASE_MOUNT = "/etc/secrets/appfnd" + + +def read_binding_keys(instance: str) -> set[str]: + """Enumerate present binding keys from mount (flat + legacy layouts) then env. + + Mirrors the three lookup strategies of ``read_from_mount_and_fallback_to_env_var``: + + 1. If ``SERVICE_BINDING_ROOT`` is set → flat path + ``$ROOT/objectstore/`` (servicebinding.io spec). + 2. Legacy path ``{base}/objectstore/{instance}/`` (always tried; falls back + from the flat attempt if SERVICE_BINDING_ROOT is set). + 3. Env-var prefix + ``CLOUD_SDK_CFG_OBJECTSTORE_{instance_upper}_`` → strip the prefix, + return the remaining key as-is. + + Returns: + Set of key names present in any of the above sources. + """ + keys: set[str] = set() + resolved_base = resolve_base_mount(_DEFAULT_BASE_MOUNT) + + # servicebinding.io flat path ($ROOT/objectstore/) + if os.environ.get("SERVICE_BINDING_ROOT") is not None: + flat_dir = os.path.join(resolved_base, "objectstore") + keys.update(_scan_dir(flat_dir)) + + # Three-level path ($ROOT/objectstore/{instance}/) + legacy_dir = os.path.join(resolved_base, "objectstore", instance) + keys.update(_scan_dir(legacy_dir)) + + # Environment variables + prefix = f"CLOUD_SDK_CFG_OBJECTSTORE_{instance.upper().replace('-', '_')}_" + for var in os.environ: + if var.upper().startswith(prefix): + key = var[len(prefix) :] + if key: + keys.add(key) + + return keys + + +def _scan_dir(directory: str) -> set[str]: + """Return file names in ``directory``, or an empty set if absent.""" + try: + return {entry.name for entry in os.scandir(directory) if entry.is_file()} + except (FileNotFoundError, NotADirectoryError, OSError): + return set() + + +def detect_provider(keys: set[str]) -> ObjectStoreProvider: + """Infer the cloud provider from a set of present binding keys. + + Args: + keys: Set of keys returned by ``read_binding_keys``. + + Returns: + Detected object store provider. + + Raises: + ValueError: If no provider can be identified from the available keys. + """ + lowered = {k.lower() for k in keys} + + def matches(provider: ObjectStoreProvider) -> bool: + return {d.lower() for d in _DISCRIMINATORS[provider]}.issubset(lowered) + + if matches(ObjectStoreProvider.AZURE): + return ObjectStoreProvider.AZURE + if matches(ObjectStoreProvider.GCS): + return ObjectStoreProvider.GCS + if matches(ObjectStoreProvider.S3): + return ObjectStoreProvider.S3 + + raise ValueError( + f"Cannot detect objectstore provider from keys: {sorted(lowered)}. " + "Expected one of: s3 (access_key_id, secret_access_key, host), " + "azure (container_uri, sas_token, container_name), " + "gcs (base64EncodedPrivateKeyData, projectId)." + ) diff --git a/src/sap_cloud_sdk/objectstore/_factory.py b/src/sap_cloud_sdk/objectstore/_factory.py new file mode 100644 index 00000000..5aa492b7 --- /dev/null +++ b/src/sap_cloud_sdk/objectstore/_factory.py @@ -0,0 +1,62 @@ +"""Object store client factory — provider detection and dispatch.""" + +from typing import Union + +from sap_cloud_sdk.objectstore._azure import AzureClient +from sap_cloud_sdk.objectstore._detect import detect_provider, read_binding_keys +from sap_cloud_sdk.objectstore._gcs import GcsClient +from sap_cloud_sdk.objectstore._protocol import ObjectStoreClient +from sap_cloud_sdk.objectstore._s3 import S3Client +from sap_cloud_sdk.objectstore.config import ( + AzureConfig, + GcsConfig, + S3Config, + load_from_env_or_mount, +) +from sap_cloud_sdk.objectstore.exceptions import ClientCreationError + + +def create_client( + instance: str, + *, + config: Union[S3Config, AzureConfig, GcsConfig, None] = None, +) -> ObjectStoreClient: + """Create an object store client with automatic provider detection. + + When ``config`` is omitted the function reads the service binding for + ``instance`` from the secret mount or environment variables, infers the + cloud provider, and returns the matching concrete client. + + Args: + instance: Instance name used for secret resolution. Must be non-empty. + config: Optional explicit client configuration. If provided, + auto-detection is skipped and this configuration is used directly. + + Returns: + A client satisfying the ``ObjectStoreClient`` protocol. + + Raises: + ValueError: If ``instance`` is empty or None. + ConfigError: If the binding cannot be loaded or is missing required fields. + ClientCreationError: If no provider can be detected or client creation fails. + """ + if not instance or not instance.strip(): + raise ValueError("instance parameter must be a non-empty string") + + if config is None: + keys = read_binding_keys(instance) + try: + provider = detect_provider(keys) + except ValueError as e: + raise ClientCreationError( + f"Cannot create objectstore client for instance '{instance}': {e}" + ) from e + config = load_from_env_or_mount(provider, instance) + + if isinstance(config, S3Config): + return S3Client(config) + if isinstance(config, AzureConfig): + return AzureClient(config) + if isinstance(config, GcsConfig): + return GcsClient(config) + raise ClientCreationError(f"Unsupported config type: {type(config).__name__}") diff --git a/src/sap_cloud_sdk/objectstore/_gcs.py b/src/sap_cloud_sdk/objectstore/_gcs.py new file mode 100644 index 00000000..01338f0f --- /dev/null +++ b/src/sap_cloud_sdk/objectstore/_gcs.py @@ -0,0 +1,306 @@ +"""Google Cloud Storage backend implementation for object store operations.""" + +import os +from typing import BinaryIO, List, NoReturn + +from sap_cloud_sdk.core.telemetry import Module, Operation, record_metrics +from sap_cloud_sdk.objectstore.config import GcsConfig +from sap_cloud_sdk.objectstore._models import ObjectMetadata +from sap_cloud_sdk.objectstore._protocol import ObjectReader +from sap_cloud_sdk.objectstore._validation import ( + validate_object_name, + validate_prefix, + validate_put_from_bytes, + validate_put_from_file, + validate_put_object, +) +from sap_cloud_sdk.objectstore.exceptions import ( + ClientCreationError, + ListObjectsError, + ObjectNotFoundError, + ObjectOperationError, +) + + +class GcsClient: + """Google Cloud Storage object storage client. + + Provides the standard 8-method object store interface backed by + Google Cloud Storage. Obtain an instance via ``create_client()``. + """ + + def __init__(self, config: GcsConfig) -> None: + """Initialise the GCS object storage client. + + Args: + config: GCS client configuration. + + Raises: + ClientCreationError: If client initialisation fails. + """ + try: + self._client = self._create_storage_client(config) + self._bucket = self._client.bucket(config.bucket) + except ClientCreationError: + raise + except Exception as e: + raise ClientCreationError(f"Failed to initialise GcsClient: {e}") from e + + def _create_storage_client(self, cfg: GcsConfig): + """Build a Google Cloud Storage Client from binding data. + + Decodes the base64-encoded service-account JSON and creates a + storage.Client using the embedded credentials. + """ + try: + import base64 + import json + + from google.cloud import storage + from google.oauth2 import service_account + + info = json.loads(base64.b64decode(cfg.base64_encoded_private_key_data)) + creds = service_account.Credentials.from_service_account_info(info) + return storage.Client(project=cfg.project_id, credentials=creds) + except ImportError as e: + raise ClientCreationError( + "google-cloud-storage is required for GCS Object Store support. " + "Install it with: pip install 'sap-cloud-sdk[gcs]'" + ) from e + except Exception as e: + raise ClientCreationError( + f"Failed to create GCS storage client: {e}" + ) from e + + @record_metrics(Module.OBJECTSTORE, Operation.OBJECTSTORE_PUT_OBJECT_FROM_BYTES) + def put_object_from_bytes(self, name: str, data: bytes, content_type: str) -> None: + """Upload an object from bytes. + + Args: + name: Name/key of the object to upload. + data: Byte data to upload. + content_type: MIME type of the object. + + Raises: + ValueError: If any parameter is invalid. + ObjectOperationError: If the upload fails. + """ + validate_put_from_bytes(name, data, content_type) + + try: + blob = self._bucket.blob(name) + blob.upload_from_string(data, content_type=content_type) + except Exception as e: + raise ObjectOperationError(f"Failed to upload object '{name}': {e}") from e + + @record_metrics(Module.OBJECTSTORE, Operation.OBJECTSTORE_PUT_OBJECT) + def put_object( + self, name: str, stream: BinaryIO, size: int, content_type: str + ) -> None: + """Upload an object from a stream. + + Args: + name: Name/key of the object to upload. + stream: Binary stream containing the object data. + size: Size of the object in bytes. + content_type: MIME type of the object. + + Raises: + ValueError: If any parameter is invalid. + ObjectOperationError: If the upload fails. + """ + validate_put_object(name, stream, size, content_type) + + try: + blob = self._bucket.blob(name) + blob.upload_from_file(stream, size=size, content_type=content_type) + except Exception as e: + raise ObjectOperationError(f"Failed to upload object '{name}': {e}") from e + + @record_metrics(Module.OBJECTSTORE, Operation.OBJECTSTORE_PUT_OBJECT_FROM_FILE) + def put_object_from_file( + self, name: str, file_path: str, content_type: str + ) -> None: + """Upload an object from a local file. + + Args: + name: Name/key of the object to upload. + file_path: Path to the local file to upload. + content_type: MIME type of the object. + + Raises: + ValueError: If any parameter is invalid. + ObjectOperationError: If the upload fails. + """ + validate_put_from_file(name, file_path, content_type) + + try: + if not os.path.isfile(file_path): + raise ObjectOperationError(f"File not found: {file_path}") + + blob = self._bucket.blob(name) + blob.upload_from_filename(file_path, content_type=content_type) + except ObjectOperationError: + raise + except Exception as e: + raise ObjectOperationError(f"Failed to upload object '{name}': {e}") from e + + @record_metrics(Module.OBJECTSTORE, Operation.OBJECTSTORE_GET_OBJECT) + def get_object(self, name: str) -> ObjectReader: + """Download an object as a stream. + + Args: + name: Name/key of the object to download. + + Returns: + A readable binary stream of the object data. + + Raises: + ValueError: If name is invalid. + ObjectNotFoundError: If the object does not exist. + ObjectOperationError: If the download fails. + """ + validate_object_name(name) + + try: + blob = self._bucket.blob(name) + blob.reload() # raises NotFound eagerly if absent; maps to ObjectNotFoundError + return blob.open("rb") + except Exception as e: + self._map_gcs_error(e, name, "download") + + @record_metrics(Module.OBJECTSTORE, Operation.OBJECTSTORE_DELETE_OBJECT) + def delete_object(self, name: str) -> None: + """Delete an object (idempotent — no error if already absent). + + Args: + name: Name/key of the object to delete. + + Raises: + ValueError: If name is invalid. + ObjectOperationError: If the deletion fails. + """ + validate_object_name(name) + + try: + blob = self._bucket.blob(name) + blob.delete() + except Exception as e: + try: + from google.cloud.exceptions import NotFound + + if isinstance(e, NotFound): + return # idempotent + except ImportError: + pass + raise ObjectOperationError(f"Failed to delete object '{name}': {e}") from e + + @record_metrics(Module.OBJECTSTORE, Operation.OBJECTSTORE_LIST_OBJECTS) + def list_objects(self, prefix: str) -> List[ObjectMetadata]: + """List objects with a given prefix. + + Args: + prefix: Prefix to filter objects by name. + + Returns: + List of object metadata. + + Raises: + ValueError: If prefix is invalid. + ListObjectsError: If listing fails. + """ + validate_prefix(prefix) + + try: + result = [] + for blob in self._client.list_blobs(self._bucket, prefix=prefix): + result.append( + ObjectMetadata( + key=blob.name, + last_modified=blob.updated, + etag=(blob.etag or "").strip('"'), + size=blob.size or 0, + storage_class=blob.storage_class, + owner=None, + ) + ) + return result + except Exception as e: + raise ListObjectsError( + f"Failed to list objects with prefix '{prefix}': {e}" + ) from e + + @record_metrics(Module.OBJECTSTORE, Operation.OBJECTSTORE_HEAD_OBJECT) + def head_object(self, name: str) -> ObjectMetadata: + """Get metadata for an object without downloading it. + + Args: + name: Name/key of the object. + + Returns: + Object metadata. + + Raises: + ValueError: If name is invalid. + ObjectNotFoundError: If the object does not exist. + ObjectOperationError: If the operation fails. + """ + validate_object_name(name) + + try: + blob = self._bucket.get_blob(name) + if blob is None: + raise ObjectNotFoundError(f"Object '{name}' not found") + return ObjectMetadata( + key=blob.name, + last_modified=blob.updated, + etag=(blob.etag or "").strip('"'), + size=blob.size or 0, + storage_class=blob.storage_class, + owner=None, + ) + except ObjectNotFoundError: + raise + except Exception as e: + raise ObjectOperationError( + f"Failed to get metadata for object '{name}': {e}" + ) from e + + @record_metrics(Module.OBJECTSTORE, Operation.OBJECTSTORE_OBJECT_EXISTS) + def object_exists(self, name: str) -> bool: + """Check if an object exists. + + Args: + name: Name/key of the object to check. + + Returns: + True if the object exists, False otherwise. + + Raises: + ValueError: If name is invalid. + ObjectOperationError: If the check fails. + """ + validate_object_name(name) + + try: + self.head_object(name) + return True + except ObjectNotFoundError: + return False + except Exception as e: + raise ObjectOperationError( + f"Failed to check if object '{name}' exists: {e}" + ) from e + + def _map_gcs_error(self, exc: Exception, name: str, operation: str) -> NoReturn: + """Map GCS SDK exceptions to objectstore exceptions and re-raise.""" + try: + from google.cloud.exceptions import NotFound + + if isinstance(exc, NotFound): + raise ObjectNotFoundError(f"Object '{name}' not found") from exc + except ImportError: + pass + raise ObjectOperationError( + f"Failed to {operation} object '{name}': {exc}" + ) from exc diff --git a/src/sap_cloud_sdk/objectstore/_models.py b/src/sap_cloud_sdk/objectstore/_models.py index 84902461..32aa8aed 100644 --- a/src/sap_cloud_sdk/objectstore/_models.py +++ b/src/sap_cloud_sdk/objectstore/_models.py @@ -2,21 +2,16 @@ from dataclasses import dataclass from datetime import datetime +from enum import StrEnum from typing import Optional -@dataclass -class ObjectStoreBindingData: - """Configuration data for object store connection credentials. +class ObjectStoreProvider(StrEnum): + """Supported object store backend providers.""" - Contains the necessary connection parameters for S3-compatible object storage. - Used internally by the SDK and can be provided explicitly to create_client(). - """ - - access_key_id: str = "" - secret_access_key: str = "" - bucket: str = "" - host: str = "" + S3 = "s3" + AZURE = "azure" + GCS = "gcs" @dataclass(frozen=True) diff --git a/src/sap_cloud_sdk/objectstore/_protocol.py b/src/sap_cloud_sdk/objectstore/_protocol.py new file mode 100644 index 00000000..4bdc37b3 --- /dev/null +++ b/src/sap_cloud_sdk/objectstore/_protocol.py @@ -0,0 +1,55 @@ +"""Protocol definition for object store clients.""" + +from types import TracebackType +from typing import BinaryIO, List, Protocol, Self, runtime_checkable + +from sap_cloud_sdk.objectstore._models import ObjectMetadata + + +class ObjectReader(Protocol): + """Managed binary reader returned by an object store backend.""" + + def read(self, size: int = -1, /) -> bytes: ... + + def close(self) -> None: ... + + def __enter__(self) -> Self: ... + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + /, + ) -> None: ... + + +@runtime_checkable +class ObjectStoreClient(Protocol): + """Protocol defining the object store client interface. + + All provider backends satisfy this protocol. + Use ``create_client()`` to obtain a concrete implementation. + """ + + def put_object_from_bytes( + self, name: str, data: bytes, content_type: str + ) -> None: ... + + def put_object( + self, name: str, stream: BinaryIO, size: int, content_type: str + ) -> None: ... + + def put_object_from_file( + self, name: str, file_path: str, content_type: str + ) -> None: ... + + def get_object(self, name: str) -> ObjectReader: ... + + def delete_object(self, name: str) -> None: ... + + def list_objects(self, prefix: str) -> List[ObjectMetadata]: ... + + def head_object(self, name: str) -> ObjectMetadata: ... + + def object_exists(self, name: str) -> bool: ... diff --git a/src/sap_cloud_sdk/objectstore/_s3.py b/src/sap_cloud_sdk/objectstore/_s3.py index cfc8168c..27212a70 100644 --- a/src/sap_cloud_sdk/objectstore/_s3.py +++ b/src/sap_cloud_sdk/objectstore/_s3.py @@ -3,65 +3,59 @@ import io import os from datetime import datetime -from http.client import HTTPResponse -from typing import BinaryIO, List, cast +from typing import BinaryIO, List import minio.datatypes from minio import Minio from minio.error import S3Error from sap_cloud_sdk.core.telemetry import Module, Operation, record_metrics +from sap_cloud_sdk.objectstore.config import S3Config +from sap_cloud_sdk.objectstore._models import ObjectMetadata +from sap_cloud_sdk.objectstore._protocol import ObjectReader +from sap_cloud_sdk.objectstore._validation import ( + validate_object_name, + validate_prefix, + validate_put_from_bytes, + validate_put_from_file, + validate_put_object, +) from sap_cloud_sdk.objectstore.exceptions import ( ClientCreationError, - ObjectOperationError, - ObjectNotFoundError, ListObjectsError, + ObjectNotFoundError, + ObjectOperationError, ) -from sap_cloud_sdk.objectstore._models import ObjectStoreBindingData, ObjectMetadata from sap_cloud_sdk.objectstore.utils import _normalize_host -# Validation error message constants -EMPTY_NAME_ERROR = "name must be a non-empty string" -EMPTY_CONTENT_TYPE_ERROR = "content_type must be a non-empty string" -EMPTY_FILE_PATH_ERROR = "file_path must be a non-empty string" -INVALID_DATA_TYPE_ERROR = "data must be bytes" -INVALID_STREAM_ERROR = "stream must be a readable binary stream" -NEGATIVE_SIZE_ERROR = "size must be non-negative" -INVALID_PREFIX_TYPE_ERROR = "prefix must be a string" - -class ObjectStoreClient: +class S3Client: """S3-compatible object storage client. Provides a unified interface for object storage operations using the MinIO client library. Supports upload, download, delete, list, and metadata operations on S3-compatible storage. """ - def __init__( - self, creds_config: ObjectStoreBindingData, *, disable_ssl: bool = False - ) -> None: + def __init__(self, config: S3Config) -> None: """Initialize the object storage client. Args: - creds_config: Connection credentials and endpoint configuration. - disable_ssl: Whether to disable SSL/TLS connections. Defaults to False. + config: S3 client configuration including credentials and runtime options. Raises: ClientCreationError: If client initialization fails. """ - - self._creds_config = creds_config - self._disable_ssl = disable_ssl + self._config = config self._minio_client = self._create_minio_client() def _create_minio_client(self) -> Minio: """Create MinIO client with proper configuration.""" try: return Minio( - endpoint=_normalize_host(self._creds_config.host), - access_key=self._creds_config.access_key_id, - secret_key=self._creds_config.secret_access_key, - secure=not self._disable_ssl, + endpoint=_normalize_host(self._config.host), + access_key=self._config.access_key_id, + secret_key=self._config.secret_access_key, + secure=not self._config.disable_ssl, ) except Exception as e: @@ -80,16 +74,11 @@ def put_object_from_bytes(self, name: str, data: bytes, content_type: str) -> No ValueError: If any parameter is invalid ObjectOperationError: If the upload fails """ - if not name: - raise ValueError(EMPTY_NAME_ERROR) - if not isinstance(data, bytes): - raise ValueError(INVALID_DATA_TYPE_ERROR) - if not content_type: - raise ValueError(EMPTY_CONTENT_TYPE_ERROR) + validate_put_from_bytes(name, data, content_type) try: self._minio_client.put_object( - bucket_name=self._creds_config.bucket, + bucket_name=self._config.bucket, object_name=name, data=io.BytesIO(data), length=len(data), @@ -118,18 +107,11 @@ def put_object( ValueError: If any parameter is invalid ObjectOperationError: If the upload fails """ - if not name: - raise ValueError(EMPTY_NAME_ERROR) - if not hasattr(stream, "read"): - raise ValueError(INVALID_STREAM_ERROR) - if size < 0: - raise ValueError(NEGATIVE_SIZE_ERROR) - if not content_type: - raise ValueError(EMPTY_CONTENT_TYPE_ERROR) + validate_put_object(name, stream, size, content_type) try: self._minio_client.put_object( - bucket_name=self._creds_config.bucket, + bucket_name=self._config.bucket, object_name=name, data=stream, length=size, @@ -157,12 +139,7 @@ def put_object_from_file( ValueError: If any parameter is invalid ObjectOperationError: If the upload fails """ - if not name: - raise ValueError(EMPTY_NAME_ERROR) - if not file_path: - raise ValueError(EMPTY_FILE_PATH_ERROR) - if not content_type: - raise ValueError(EMPTY_CONTENT_TYPE_ERROR) + validate_put_from_file(name, file_path, content_type) try: # Check if file exists and get size @@ -173,7 +150,7 @@ def put_object_from_file( with open(file_path, "rb") as file_stream: self._minio_client.put_object( - bucket_name=self._creds_config.bucket, + bucket_name=self._config.bucket, object_name=name, data=file_stream, length=file_size, @@ -187,29 +164,25 @@ def put_object_from_file( raise ObjectOperationError(f"Failed to upload object '{name}': {e}") from e @record_metrics(Module.OBJECTSTORE, Operation.OBJECTSTORE_GET_OBJECT) - def get_object(self, name: str) -> HTTPResponse: + def get_object(self, name: str) -> ObjectReader: """Download an object as a stream. Args: name: Name/key of the object to download Returns: - HTTPResponse stream of the object data + A readable binary stream of the object data Raises: ValueError: If name is invalid ObjectNotFoundError: If the object doesn't exist ObjectOperationError: If the download fails """ - if not name: - raise ValueError(EMPTY_NAME_ERROR) + validate_object_name(name) try: - response = cast( - HTTPResponse, - self._minio_client.get_object( - bucket_name=self._creds_config.bucket, object_name=name - ), + response = self._minio_client.get_object( + bucket_name=self._config.bucket, object_name=name ) return response except S3Error as e: @@ -234,12 +207,11 @@ def delete_object(self, name: str) -> None: ValueError: If name is invalid ObjectOperationError: If the deletion fails """ - if not name: - raise ValueError(EMPTY_NAME_ERROR) + validate_object_name(name) try: self._minio_client.remove_object( - bucket_name=self._creds_config.bucket, object_name=name + bucket_name=self._config.bucket, object_name=name ) except S3Error as e: if e.code != "NoSuchKey": @@ -264,13 +236,12 @@ def list_objects(self, prefix: str) -> List[ObjectMetadata]: ValueError: If prefix is invalid ListObjectsError: If listing fails """ - if not isinstance(prefix, str): - raise ValueError(INVALID_PREFIX_TYPE_ERROR) + validate_prefix(prefix) result = [] try: objects = self._minio_client.list_objects( - bucket_name=self._creds_config.bucket, prefix=prefix + bucket_name=self._config.bucket, prefix=prefix ) for obj in objects: @@ -309,12 +280,11 @@ def head_object(self, name: str) -> ObjectMetadata: ObjectNotFoundError: If the object doesn't exist ObjectOperationError: If the operation fails """ - if not name: - raise ValueError(EMPTY_NAME_ERROR) + validate_object_name(name) try: stat: minio.datatypes.Object = self._minio_client.stat_object( - bucket_name=self._creds_config.bucket, object_name=name + bucket_name=self._config.bucket, object_name=name ) return ObjectMetadata( @@ -350,8 +320,7 @@ def object_exists(self, name: str) -> bool: ValueError: If name is invalid ObjectOperationError: If the check fails """ - if not name: - raise ValueError(EMPTY_NAME_ERROR) + validate_object_name(name) try: self.head_object(name) diff --git a/src/sap_cloud_sdk/objectstore/_validation.py b/src/sap_cloud_sdk/objectstore/_validation.py new file mode 100644 index 00000000..1c4489ad --- /dev/null +++ b/src/sap_cloud_sdk/objectstore/_validation.py @@ -0,0 +1,46 @@ +"""Argument-validation helpers shared by object store backends.""" + +from typing import BinaryIO + + +def validate_object_name(name: str) -> None: + """Require a non-empty object name/key.""" + if not name: + raise ValueError("name must be a non-empty string") + + +def validate_prefix(prefix: str) -> None: + """Require the list prefix to be a string (empty is allowed).""" + if not isinstance(prefix, str): + raise ValueError("prefix must be a string") + + +def validate_put_from_bytes(name: str, data: bytes, content_type: str) -> None: + """Validate arguments for an upload from an in-memory byte string.""" + validate_object_name(name) + if not isinstance(data, bytes): + raise ValueError("data must be bytes") + if not content_type: + raise ValueError("content_type must be a non-empty string") + + +def validate_put_object( + name: str, stream: BinaryIO, size: int, content_type: str +) -> None: + """Validate arguments for an upload from a binary stream.""" + validate_object_name(name) + if not hasattr(stream, "read"): + raise ValueError("stream must be a readable binary stream") + if size < 0: + raise ValueError("size must be non-negative") + if not content_type: + raise ValueError("content_type must be a non-empty string") + + +def validate_put_from_file(name: str, file_path: str, content_type: str) -> None: + """Validate arguments for an upload from a local file path.""" + validate_object_name(name) + if not file_path: + raise ValueError("file_path must be a non-empty string") + if not content_type: + raise ValueError("content_type must be a non-empty string") diff --git a/src/sap_cloud_sdk/objectstore/config.py b/src/sap_cloud_sdk/objectstore/config.py new file mode 100644 index 00000000..19235df4 --- /dev/null +++ b/src/sap_cloud_sdk/objectstore/config.py @@ -0,0 +1,214 @@ +"""Binding data and client configuration for object store backends.""" + +from dataclasses import dataclass, field +from typing import Union + +from sap_cloud_sdk.core.secret_resolver import read_from_mount_and_fallback_to_env_var +from sap_cloud_sdk.objectstore._models import ObjectStoreProvider +from sap_cloud_sdk.objectstore.exceptions import ConfigError + + +@dataclass +class S3Config: + """Client configuration for S3-compatible object storage. + + Args: + access_key_id: S3 access key. + secret_access_key: S3 secret key. + bucket: Target bucket name. + host: S3-compatible endpoint host. + disable_ssl: Disable TLS for the MinIO connection. Useful for local + development against an HTTP-only MinIO instance. Defaults to False. + """ + + access_key_id: str + secret_access_key: str + bucket: str + host: str + disable_ssl: bool = False + + +@dataclass +class AzureConfig: + """Client configuration for Azure Blob Storage. + + Args: + container_name: Container name. + container_uri: Full container URI. + sas_token: Shared access signature token. + """ + + container_name: str + container_uri: str + sas_token: str + + +@dataclass +class GcsConfig: + """Client configuration for Google Cloud Storage. + + Args: + base64_encoded_private_key_data: Base64-encoded service account JSON. + project_id: GCP project ID. + bucket: Target bucket name. + """ + + base64_encoded_private_key_data: str + project_id: str + bucket: str + + +@dataclass +class S3BindingData: + """Raw service-binding credentials for S3-compatible object storage. + + Filled by the secret resolver; all fields are plain strings. + """ + + access_key_id: str = "" + secret_access_key: str = "" + bucket: str = "" + host: str = "" + + def validate(self) -> None: + """Raise ConfigError if any runtime-required field is empty.""" + missing = [ + name + for name, value in [ + ("access_key_id", self.access_key_id), + ("secret_access_key", self.secret_access_key), + ("bucket", self.bucket), + ("host", self.host), + ] + if not value + ] + if missing: + raise ConfigError( + f"s3 binding is missing required field(s): {', '.join(missing)}" + ) + + def to_config(self, *, disable_ssl: bool = False) -> S3Config: + """Return an S3Config with credentials from this binding.""" + return S3Config( + access_key_id=self.access_key_id, + secret_access_key=self.secret_access_key, + bucket=self.bucket, + host=self.host, + disable_ssl=disable_ssl, + ) + + +@dataclass +class AzureBindingData: + """Raw service-binding credentials for Azure Blob Storage. + + Filled by the secret resolver; all fields are plain strings. + """ + + container_name: str = "" + container_uri: str = "" + sas_token: str = "" + + def validate(self) -> None: + """Raise ConfigError if any runtime-required field is empty.""" + missing = [ + name + for name, value in [ + ("container_name", self.container_name), + ("container_uri", self.container_uri), + ("sas_token", self.sas_token), + ] + if not value + ] + if missing: + raise ConfigError( + f"azure binding is missing required field(s): {', '.join(missing)}" + ) + + def to_config(self) -> AzureConfig: + """Return an AzureConfig with credentials from this binding.""" + return AzureConfig( + container_name=self.container_name, + container_uri=self.container_uri, + sas_token=self.sas_token, + ) + + +@dataclass +class GcsBindingData: + """Raw service-binding credentials for Google Cloud Storage. + + Filled by the secret resolver; all fields are plain strings. + """ + + base64EncodedPrivateKeyData: str = field( + default="", metadata={"secret": "base64EncodedPrivateKeyData"} + ) + projectId: str = field(default="", metadata={"secret": "projectId"}) + bucket: str = "" + + def validate(self) -> None: + """Raise ConfigError if any runtime-required field is empty.""" + missing = [ + name + for name, value in [ + ("base64EncodedPrivateKeyData", self.base64EncodedPrivateKeyData), + ("projectId", self.projectId), + ("bucket", self.bucket), + ] + if not value + ] + if missing: + raise ConfigError( + f"gcs binding is missing required field(s): {', '.join(missing)}" + ) + + def to_config(self) -> GcsConfig: + """Return a GcsConfig with credentials from this binding.""" + return GcsConfig( + base64_encoded_private_key_data=self.base64EncodedPrivateKeyData, + project_id=self.projectId, + bucket=self.bucket, + ) + + +_BINDING_TYPES: dict[ + ObjectStoreProvider, + type[S3BindingData] | type[AzureBindingData] | type[GcsBindingData], +] = { + ObjectStoreProvider.S3: S3BindingData, + ObjectStoreProvider.AZURE: AzureBindingData, + ObjectStoreProvider.GCS: GcsBindingData, +} + + +def load_from_env_or_mount( + provider: ObjectStoreProvider, instance: str +) -> Union[S3Config, AzureConfig, GcsConfig]: + """Resolve, validate, and wrap the binding for a detected provider. + + Args: + provider: The provider detected for this instance. + instance: Logical instance name used for secret resolution. + + Returns: + The validated config for ``provider``. + + Raises: + ConfigError: If loading or validation fails. + """ + binding = _BINDING_TYPES[provider]() + try: + read_from_mount_and_fallback_to_env_var( + base_volume_mount="/etc/secrets/appfnd", + base_var_name="CLOUD_SDK_CFG", + module="objectstore", + instance=instance, + target=binding, + ) + except Exception as e: + raise ConfigError( + f"failed to load objectstore configuration for instance='{instance}': {e}" + ) from e + binding.validate() + return binding.to_config() diff --git a/src/sap_cloud_sdk/objectstore/exceptions.py b/src/sap_cloud_sdk/objectstore/exceptions.py index d54237da..6a331ec3 100644 --- a/src/sap_cloud_sdk/objectstore/exceptions.py +++ b/src/sap_cloud_sdk/objectstore/exceptions.py @@ -7,6 +7,12 @@ class ObjectStoreError(Exception): pass +class ConfigError(ObjectStoreError): + """Raised when loading or validating object store configuration fails.""" + + pass + + class ClientCreationError(ObjectStoreError): """Raised when object store client creation fails.""" diff --git a/src/sap_cloud_sdk/objectstore/user-guide.md b/src/sap_cloud_sdk/objectstore/user-guide.md index 58ae48ed..6875b7d5 100644 --- a/src/sap_cloud_sdk/objectstore/user-guide.md +++ b/src/sap_cloud_sdk/objectstore/user-guide.md @@ -1,8 +1,8 @@ # ObjectStore User Guide -This module provides a simple API for interacting with S3-compatible object storage. +Provides a simple and unified way to connect to Object Store services on SAP BTP. It abstracts configuration, authentication, and transport, making it easy to upload and download files without dealing with provider-specific details. -Provides a simple and unified way to connect to Object Store services. It abstracts configuration, authentication, and transport, making it easy to upload and download files without dealing with provider-specific details. +**Supported providers:** Amazon S3 (and S3-compatible services like MinIO), Azure Blob Storage, and Google Cloud Storage. ## Installation @@ -19,38 +19,68 @@ See further information about installation in the [main documentation](/README.m ## Import ```python -from sap_cloud_sdk.objectstore import create_client, ObjectStoreClient -from sap_cloud_sdk.objectstore import ObjectStoreBindingData +from sap_cloud_sdk.objectstore import ObjectStoreClient, create_client ``` --- ## Getting Started -Use `create_client()` to get a client with automatic configuration detection: +Use `create_client()` with the logical instance name from your Cloud descriptor. +When no configuration is supplied, it reads the binding, detects S3, Azure, or +GCS from its keys, and creates the matching client. ```python from sap_cloud_sdk.objectstore import create_client -# Automatically detects local vs cloud mode client = create_client("my-instance") ``` -You can also specify additional parameters if needed: +> **`instance` refers to the instance name defined in your Cloud descriptor.** +> It determines the credentials or mounted secrets that are resolved. + +### Explicit Configuration + +Pass a public configuration type to bypass service-binding discovery: ```python -from sap_cloud_sdk.objectstore import create_client +from sap_cloud_sdk.objectstore import S3Config, create_client -# Custom configuration with SSL disabled client = create_client( - "my-instance", - disable_ssl=True, # Disable SSL (default is False) + "local-minio", + config=S3Config( + access_key_id="...", + secret_access_key="...", + bucket="my-bucket", + host="localhost:9000", + disable_ssl=True, # Plain HTTP; (default is False) + ), ) ``` -> **`instance` refers to the instance name defined in your Cloud descriptor.** -> -> This name determines which set of credentials or mounted secrets to resolve from the environment. +For Azure Blob Storage and GCS, pass `AzureConfig` or `GcsConfig` respectively: + +```python +from sap_cloud_sdk.objectstore import AzureConfig, GcsConfig, create_client + +azure_client = create_client( + "azure-store", + config=AzureConfig( + container_name="my-container", + container_uri="https://my-account.blob.core.windows.net/my-container", + sas_token="...", + ), +) + +gcs_client = create_client( + "gcs-store", + config=GcsConfig( + base64_encoded_private_key_data="...", + project_id="my-project", + bucket="my-bucket", + ), +) +``` --- @@ -59,7 +89,6 @@ client = create_client( ### From Bytes ```python -# Upload binary data directly data = b"Hello, World!" client.put_object_from_bytes(name="hello.txt", data=data, content_type="text/plain") ``` @@ -67,7 +96,6 @@ client.put_object_from_bytes(name="hello.txt", data=data, content_type="text/pla ### From File ```python -# Upload from a local file client.put_object_from_file( name="document.pdf", file_path="/path/to/local/document.pdf", @@ -79,8 +107,8 @@ client.put_object_from_file( ```python import io +import os -# Upload from a stream/file-like object stream = io.BytesIO(b"Streamed content") client.put_object( name="stream.txt", @@ -89,15 +117,12 @@ client.put_object( content_type="text/plain", ) -# Or with a real file object -with open("/path/to/file.txt", "rb") as f: - # Get file size - import os - - size = os.path.getsize("/path/to/file.txt") - +with open("/path/to/file.txt", "rb") as file: client.put_object( - name="uploaded.txt", stream=f, size=size, content_type="text/plain" + name="uploaded.txt", + stream=file, + size=os.path.getsize("/path/to/file.txt"), + content_type="text/plain", ) ``` @@ -107,26 +132,28 @@ with open("/path/to/file.txt", "rb") as f: ### Get Object Content -```python -# Download an object -response = client.get_object("hello.txt") +`get_object()` returns an `ObjectReader`. Its `read()`, `read(size)`, `close()`, +and context-manager operations are portable across all supported providers. -# Read the content -content = response.read() # Returns bytes -text_content = content.decode("utf-8") # Convert to string if needed +```python +with client.get_object("hello.txt") as response: + content = response.read() + text_content = content.decode("utf-8") +``` -# Don't forget to close the response -response.close() +You can close a reader explicitly when a context manager is not practical: -# Or use as context manager (automatically closes) -with client.get_object("hello.txt") as response: +```python +response = client.get_object("hello.txt") +try: content = response.read() +finally: + response.close() ``` ### Check Object Existence ```python -# Check if an object exists if client.object_exists("hello.txt"): print("File exists!") else: @@ -136,7 +163,6 @@ else: ### Get Object Metadata ```python -# Get object metadata without downloading content metadata = client.head_object("hello.txt") print(f"Key: {metadata.key}") @@ -149,13 +175,10 @@ print(f"Content Type: {metadata.content_type}") ### List Objects ```python -# List all objects -all_objects = client.list_objects() +# An empty prefix lists all objects. +all_objects = client.list_objects(prefix="") -# List objects with a prefix documents = client.list_objects(prefix="documents/") - -# Process the results for obj in documents: print(f"{obj.key} - {obj.size} bytes - {obj.last_modified}") ``` @@ -165,11 +188,10 @@ for obj in documents: ## Deleting Objects ```python -# Delete a single object client.delete_object("hello.txt") -# Delete operation is idempotent - no error if object doesn't exist -client.delete_object("non-existent.txt") # This won't raise an error +# Deletion is idempotent: no error if the object does not exist. +client.delete_object("non-existent.txt") ``` --- @@ -178,68 +200,113 @@ client.delete_object("non-existent.txt") # This won't raise an error - **Supported:** No (Object Store is not multi-tenant aware) - **Authentication:** N/A -- **How to use:** Multi-tenancy is not supported by this service. Object Store uses static access key credentials. Each service binding is scoped to a single dedicated bucket. To serve multiple tenants, provision a separate service instance per tenant. +- **How to use:** Object Store uses static access-key or provider service + credentials. Each service binding is scoped to one storage container or bucket. + To serve multiple tenants, provision a separate service instance per tenant. - **Further reading:** - - [SAP Object Store Service — SAP Help Portal](https://help.sap.com/docs/object-store) + [SAP Object Store Service — SAP Help Portal](https://help.sap.com/docs/object-store) ## Error Handling -The ObjectStore module provides specific exceptions for different error scenarios: +The module exposes specific exceptions for configuration, client creation, and +object operations. ```python from sap_cloud_sdk.objectstore import ( - ObjectNotFoundError, - ObjectOperationError, ClientCreationError, + ConfigError, ListObjectsError, + ObjectNotFoundError, + ObjectOperationError, ) try: - content = client.get_object("missing-file.txt") + with client.get_object("missing-file.txt") as response: + content = response.read() except ObjectNotFoundError: print("File not found") -except ObjectOperationError as e: - print(f"Operation failed: {e}") +except ObjectOperationError as error: + print(f"Operation failed: {error}") try: - client.put_object_from_bytes("test.txt", b"data", "text/plain") -except ObjectOperationError as e: - print(f"Upload failed: {e}") + client = create_client("my-instance") +except ConfigError as error: + print(f"Invalid or incomplete binding: {error}") +except ClientCreationError as error: + print(f"Could not detect or create a client: {error}") try: - objects = client.list_objects("folder/") -except ListObjectsError as e: - print(f"Failed to list objects: {e}") + objects = client.list_objects(prefix="folder/") +except ListObjectsError as error: + print(f"Failed to list objects: {error}") ``` --- ## Configuration -### Service Binding +### Provider Detection and Binding Discovery + +With no explicit `config`, `create_client()` detects the provider from binding +keys. Detection is case-insensitive. Binding values are loaded in this order: + +1. When `SERVICE_BINDING_ROOT` is set, the servicebinding.io flat path: + `$SERVICE_BINDING_ROOT/objectstore/`. +2. The legacy instance path: `$SERVICE_BINDING_ROOT/objectstore/{instance}/` + (or `/etc/secrets/appfnd/objectstore/{instance}/` when + `SERVICE_BINDING_ROOT` is unset). +3. Environment variables named + `CLOUD_SDK_CFG_OBJECTSTORE_{INSTANCE}_{FIELD}`, with the instance and field + uppercased and hyphens in the instance replaced by underscores. -- **Mount path**: `$SERVICE_BINDING_ROOT/objectstore/{instance}/` (defaults to `/etc/secrets/appfnd/objectstore/{instance}/`) -- **Required Keys**: `access_key_id`, `secret_access_key`, `bucket`, `host` -- **Env var fallback**: `CLOUD_SDK_CFG_OBJECTSTORE_{INSTANCE}_{FIELD}` (uppercased, hyphens in instance replaced with `_`) +See the [Secret Resolver guide](../core/secret_resolver/user-guide.md) for the +general mounting conventions. -> **Note:** `SERVICE_BINDING_ROOT` defaults to `/etc/secrets/appfnd` when not set. See the [Secret Resolver guide](../core/secret_resolver/user-guide.md) for details. +### Amazon S3 Configuration -#### Mounted Secrets (Kubernetes) +**Required binding keys (mounted files or env vars):** +- `access_key_id` — S3 access key ID +- `secret_access_key` — S3 secret access key +- `bucket` — Bucket name +- `host` — S3-compatible endpoint (e.g. `s3.eu-central-1.amazonaws.com`) +**Environment variables** for instance `my-instance`: + +```bash +export CLOUD_SDK_CFG_OBJECTSTORE_MY_INSTANCE_ACCESS_KEY_ID="your-access-key" +export CLOUD_SDK_CFG_OBJECTSTORE_MY_INSTANCE_SECRET_ACCESS_KEY="your-secret-key" +export CLOUD_SDK_CFG_OBJECTSTORE_MY_INSTANCE_BUCKET="your-bucket-name" +export CLOUD_SDK_CFG_OBJECTSTORE_MY_INSTANCE_HOST="s3.eu-central-1.amazonaws.com" ``` -$SERVICE_BINDING_ROOT/objectstore/{instance}/ -├── access_key_id -├── secret_access_key -├── bucket -└── host + +Supported S3 endpoints: `s3.{region}.amazonaws.com`, MinIO (`localhost:9000`), or any S3-compatible service. + +### Azure Blob Storage Configuration + +**Required binding keys (mounted files or env vars):** +- `container_uri` — Full Azure container URI (e.g. `https://{account}.blob.core.windows.net/{container}`) +- `sas_token` — Shared Access Signature (SAS) token +- `container_name` — Azure container name + +**Environment variables** for instance `my-instance`: + +```bash +export CLOUD_SDK_CFG_OBJECTSTORE_MY_INSTANCE_CONTAINER_URI="https://mystorageaccount.blob.core.windows.net/my-container" +export CLOUD_SDK_CFG_OBJECTSTORE_MY_INSTANCE_SAS_TOKEN="sp=racwdl&st=2024-01-01T00:00:00Z&..." +export CLOUD_SDK_CFG_OBJECTSTORE_MY_INSTANCE_CONTAINER_NAME="my-container" ``` -#### Environment Variables +### Google Cloud Storage Configuration + +**Required binding keys (mounted files or env vars):** +- `base64EncodedPrivateKeyData` — Base64-encoded service account JSON (camelCase filename for mounted bindings) +- `projectId` — GCP project ID (camelCase filename for mounted bindings) +- `bucket` — Bucket name + +**Environment variables** for instance `my-instance`: ```bash -# Example for ObjectStore with instance name "credentials" -export CLOUD_SDK_CFG_OBJECTSTORE_CREDENTIALS_ACCESS_KEY_ID="your-access-key" -export CLOUD_SDK_CFG_OBJECTSTORE_CREDENTIALS_SECRET_ACCESS_KEY="your-secret-key" -export CLOUD_SDK_CFG_OBJECTSTORE_CREDENTIALS_BUCKET="your-bucket-name" -export CLOUD_SDK_CFG_OBJECTSTORE_CREDENTIALS_HOST="s3.amazonaws.com" +export CLOUD_SDK_CFG_OBJECTSTORE_MY_INSTANCE_BASE64ENCODEDPRIVATEKEYDATA="..." +export CLOUD_SDK_CFG_OBJECTSTORE_MY_INSTANCE_PROJECTID="my-gcp-project" +export CLOUD_SDK_CFG_OBJECTSTORE_MY_INSTANCE_BUCKET="my-bucket" ``` diff --git a/tests/objectstore/integration/conftest.py b/tests/objectstore/integration/conftest.py index d9971227..d4a9cc0d 100644 --- a/tests/objectstore/integration/conftest.py +++ b/tests/objectstore/integration/conftest.py @@ -9,8 +9,8 @@ import pytest from dotenv import load_dotenv -from sap_cloud_sdk.objectstore import create_client -from sap_cloud_sdk.objectstore._models import ObjectStoreBindingData +from sap_cloud_sdk.objectstore._s3 import S3Client +from sap_cloud_sdk.objectstore.config import S3Config logger = logging.getLogger(__name__) @@ -68,14 +68,15 @@ def integration_env() -> Dict[str, str]: def objectstore_client(integration_env): """Create an ObjectStore client for cloud testing using explicit configuration.""" try: - config = ObjectStoreBindingData( + disable_ssl = integration_env.get("CLOUD_SDK_CFG_OBJECTSTORE_DEFAULT_SSL_ENABLED", "true").lower() in ("false", "0") + config = S3Config( host=integration_env["CLOUD_SDK_CFG_OBJECTSTORE_DEFAULT_HOST"], access_key_id=integration_env["CLOUD_SDK_CFG_OBJECTSTORE_DEFAULT_ACCESS_KEY_ID"], secret_access_key=integration_env["CLOUD_SDK_CFG_OBJECTSTORE_DEFAULT_SECRET_ACCESS_KEY"], bucket=integration_env["CLOUD_SDK_CFG_OBJECTSTORE_DEFAULT_BUCKET"], + disable_ssl=disable_ssl, ) - disable_ssl = integration_env.get("CLOUD_SDK_CFG_OBJECTSTORE_DEFAULT_SSL_ENABLED", "true").lower() in ("false", "0") - client = create_client("default", config=config, disable_ssl=disable_ssl) + client = S3Client(config) return client except Exception as e: pytest.fail(f"Failed to create ObjectStore client for cloud integration tests: {e}") @@ -180,7 +181,7 @@ def register_object(object_name: str): @pytest.fixture def failure_simulation(integration_env): """Utilities for simulating various failure conditions using explicit configuration.""" - base_config = ObjectStoreBindingData( + base_config = S3Config( host=integration_env["CLOUD_SDK_CFG_OBJECTSTORE_DEFAULT_HOST"], access_key_id=integration_env["CLOUD_SDK_CFG_OBJECTSTORE_DEFAULT_ACCESS_KEY_ID"], secret_access_key=integration_env["CLOUD_SDK_CFG_OBJECTSTORE_DEFAULT_SECRET_ACCESS_KEY"], @@ -191,23 +192,25 @@ def failure_simulation(integration_env): class FailureSimulator: def create_client_with_network_failure(self): """Create a client configured with an unreachable endpoint.""" - cfg = ObjectStoreBindingData( + cfg = S3Config( host="unreachable-endpoint.invalid:9000", access_key_id=base_config.access_key_id, secret_access_key=base_config.secret_access_key, bucket=base_config.bucket, + disable_ssl=disable_ssl, ) - return create_client("default", config=cfg, disable_ssl=disable_ssl) + return S3Client(cfg) def create_client_with_permission_denied(self): """Create a client configured with invalid credentials.""" - cfg = ObjectStoreBindingData( + cfg = S3Config( host=base_config.host, access_key_id="invalid-access-key", secret_access_key="invalid-secret-key", bucket=base_config.bucket, + disable_ssl=disable_ssl, ) - return create_client("default", config=cfg, disable_ssl=disable_ssl) + return S3Client(cfg) def setup_intermittent_failure(self): """Placeholder for intermittent failure setup.""" diff --git a/tests/objectstore/integration/test_objectstore_bdd.py b/tests/objectstore/integration/test_objectstore_bdd.py index 91516853..c1cc862b 100644 --- a/tests/objectstore/integration/test_objectstore_bdd.py +++ b/tests/objectstore/integration/test_objectstore_bdd.py @@ -278,8 +278,8 @@ def upload_with_specific_timeout(context): @when("I download the object") def download_object(context): try: - content_stream = context.client.get_object(context.object_name) - context.downloaded_content = content_stream.read() + with context.client.get_object(context.object_name) as content_stream: + context.downloaded_content = content_stream.read() context.last_error = None except Exception as e: context.last_error = e diff --git a/tests/objectstore/unit/test_azure_client.py b/tests/objectstore/unit/test_azure_client.py new file mode 100644 index 00000000..696b4c48 --- /dev/null +++ b/tests/objectstore/unit/test_azure_client.py @@ -0,0 +1,375 @@ +"""Tests for AzureClient object store backend.""" + +import io +from datetime import datetime +from unittest.mock import MagicMock, patch + +import pytest + +pytest.importorskip("azure.storage.blob") + +from azure.core.exceptions import HttpResponseError, ResourceNotFoundError # noqa: E402 +from azure.storage.blob import ContentSettings # noqa: E402 + +from sap_cloud_sdk.objectstore._azure import AzureClient # noqa: E402 +from sap_cloud_sdk.objectstore.config import AzureConfig # noqa: E402 +from sap_cloud_sdk.objectstore.exceptions import ( # noqa: E402 + ObjectNotFoundError, + ObjectOperationError, +) + +_CREDS = AzureConfig( + container_name="container", + container_uri="https://account.blob.core.windows.net/container", + sas_token="sv=2020", +) + + +def _make_client(mock_container): + """Construct AzureClient with a patched container.""" + with patch.object(AzureClient, "_create_container_client", return_value=mock_container): + return AzureClient(_CREDS) + + +class TestAzureClientPutObjectFromBytes: + + def test_put_object_from_bytes_happy_path(self): + container = MagicMock() + blob_client = MagicMock() + container.get_blob_client.return_value = blob_client + client = _make_client(container) + + client.put_object_from_bytes("test.txt", b"hello", "text/plain") + + container.get_blob_client.assert_called_once_with("test.txt") + blob_client.upload_blob.assert_called_once() + call_kwargs = blob_client.upload_blob.call_args + assert call_kwargs.kwargs.get("overwrite") is True + cs = call_kwargs.kwargs.get("content_settings") + assert isinstance(cs, ContentSettings) + + def test_put_object_from_bytes_empty_name_raises(self): + client = _make_client(MagicMock()) + with pytest.raises(ValueError, match="name must be a non-empty string"): + client.put_object_from_bytes("", b"data", "text/plain") + + def test_put_object_from_bytes_non_bytes_data_raises(self): + client = _make_client(MagicMock()) + with pytest.raises(ValueError, match="data must be bytes"): + client.put_object_from_bytes("key", "not bytes", "text/plain") + + def test_put_object_from_bytes_empty_content_type_raises(self): + client = _make_client(MagicMock()) + with pytest.raises(ValueError, match="content_type must be a non-empty string"): + client.put_object_from_bytes("key", b"data", "") + + +class TestAzureClientPutObject: + + def test_put_object_happy_path(self): + container = MagicMock() + blob_client = MagicMock() + container.get_blob_client.return_value = blob_client + client = _make_client(container) + stream = io.BytesIO(b"data") + + client.put_object("test.txt", stream, 4, "application/octet-stream") + + blob_client.upload_blob.assert_called_once() + call_kwargs = blob_client.upload_blob.call_args + assert call_kwargs.kwargs.get("length") == 4 + assert call_kwargs.kwargs.get("overwrite") is True + + def test_put_object_negative_size_raises(self): + client = _make_client(MagicMock()) + with pytest.raises(ValueError, match="size must be non-negative"): + client.put_object("key", io.BytesIO(b""), -1, "text/plain") + + def test_put_object_invalid_stream_raises(self): + client = _make_client(MagicMock()) + with pytest.raises(ValueError, match="stream must be"): + client.put_object("key", "not-a-stream", 0, "text/plain") + + +class TestAzureClientPutObjectFromFile: + + def test_put_object_from_file_empty_name_raises(self): + client = _make_client(MagicMock()) + with pytest.raises(ValueError, match="name must be a non-empty string"): + client.put_object_from_file("", "/path/file.txt", "text/plain") + + def test_put_object_from_file_empty_path_raises(self): + client = _make_client(MagicMock()) + with pytest.raises(ValueError, match="file_path must be a non-empty string"): + client.put_object_from_file("key", "", "text/plain") + + def test_put_object_from_file_missing_file_raises(self): + container = MagicMock() + container.get_blob_client.return_value = MagicMock() + client = _make_client(container) + with pytest.raises(ObjectOperationError, match="File not found"): + client.put_object_from_file("key", "/nonexistent/path.txt", "text/plain") + + +class TestAzureClientGetObject: + + def test_get_object_happy_path(self): + container = MagicMock() + blob_client = MagicMock() + mock_stream = MagicMock() + mock_stream.read.return_value = b"test" + blob_client.download_blob.return_value = mock_stream + container.get_blob_client.return_value = blob_client + client = _make_client(container) + + result = client.get_object("test.txt") + + blob_client.download_blob.assert_called_once() + assert result.read(4) == b"test" + mock_stream.read.assert_called_once_with(4) + + def test_get_object_reader_close_is_idempotent(self): + container = MagicMock() + blob_client = MagicMock() + mock_stream = MagicMock() + blob_client.download_blob.return_value = mock_stream + container.get_blob_client.return_value = blob_client + reader = _make_client(container).get_object("test.txt") + + reader.close() + reader.close() + + mock_stream.close.assert_not_called() + with pytest.raises(ValueError, match="closed object reader"): + reader.read() + with pytest.raises(ValueError, match="closed object reader"): + with reader: + pass + + def test_get_object_reader_context_manager_closes_on_error(self): + container = MagicMock() + blob_client = MagicMock() + mock_stream = MagicMock() + blob_client.download_blob.return_value = mock_stream + container.get_blob_client.return_value = blob_client + reader = _make_client(container).get_object("test.txt") + + with pytest.raises(RuntimeError, match="boom"): + with reader as entered: + assert entered is reader + raise RuntimeError("boom") + + with pytest.raises(ValueError, match="closed object reader"): + reader.read() + + def test_get_object_not_found_via_resource_not_found_error(self): + container = MagicMock() + blob_client = MagicMock() + blob_client.download_blob.side_effect = ResourceNotFoundError("not found") + container.get_blob_client.return_value = blob_client + client = _make_client(container) + + with pytest.raises(ObjectNotFoundError, match="Object 'missing.txt' not found"): + client.get_object("missing.txt") + + def test_get_object_not_found_via_http_404(self): + container = MagicMock() + blob_client = MagicMock() + err = HttpResponseError("404") + err.status_code = 404 + blob_client.download_blob.side_effect = err + container.get_blob_client.return_value = blob_client + client = _make_client(container) + + with pytest.raises(ObjectNotFoundError): + client.get_object("missing.txt") + + def test_get_object_empty_name_raises(self): + client = _make_client(MagicMock()) + with pytest.raises(ValueError, match="name must be a non-empty string"): + client.get_object("") + + +class TestAzureClientDeleteObject: + + def test_delete_object_happy_path(self): + container = MagicMock() + blob_client = MagicMock() + container.get_blob_client.return_value = blob_client + client = _make_client(container) + + client.delete_object("test.txt") + + blob_client.delete_blob.assert_called_once() + + def test_delete_object_idempotent_swallows_resource_not_found(self): + container = MagicMock() + blob_client = MagicMock() + blob_client.delete_blob.side_effect = ResourceNotFoundError("already gone") + container.get_blob_client.return_value = blob_client + client = _make_client(container) + + # Should not raise + client.delete_object("test.txt") + + def test_delete_object_empty_name_raises(self): + client = _make_client(MagicMock()) + with pytest.raises(ValueError, match="name must be a non-empty string"): + client.delete_object("") + + +class TestAzureClientListObjects: + + def test_list_objects_happy_path(self): + container = MagicMock() + blob1 = MagicMock() + blob1.name = "prefix/file1.txt" + blob1.last_modified = datetime(2023, 1, 1) + blob1.etag = '"abc123"' + blob1.size = 100 + blob1.blob_tier = None + container.list_blobs.return_value = [blob1] + client = _make_client(container) + + result = client.list_objects("prefix/") + + container.list_blobs.assert_called_once_with(name_starts_with="prefix/") + assert len(result) == 1 + assert result[0].key == "prefix/file1.txt" + assert result[0].etag == "abc123" # quotes stripped + assert result[0].size == 100 + assert result[0].owner is None + assert result[0].storage_class is None + + def test_list_objects_with_blob_tier_sets_storage_class(self): + container = MagicMock() + blob1 = MagicMock() + blob1.name = "file.txt" + blob1.last_modified = datetime(2023, 1, 1) + blob1.etag = "etag1" + blob1.size = 50 + blob1.blob_tier = "Hot" + container.list_blobs.return_value = [blob1] + client = _make_client(container) + + result = client.list_objects("") + + assert result[0].storage_class == "Hot" + + def test_list_objects_invalid_prefix_type_raises(self): + client = _make_client(MagicMock()) + with pytest.raises(ValueError, match="prefix must be a string"): + client.list_objects(123) + + +class TestAzureClientHeadObject: + + def test_head_object_happy_path(self): + container = MagicMock() + blob_client = MagicMock() + props = MagicMock() + props.last_modified = datetime(2023, 6, 15) + props.etag = '"etag42"' + props.size = 512 + props.blob_tier = None + blob_client.get_blob_properties.return_value = props + container.get_blob_client.return_value = blob_client + client = _make_client(container) + + result = client.head_object("file.txt") + + assert result.key == "file.txt" + assert result.etag == "etag42" + assert result.size == 512 + assert result.owner is None + assert result.storage_class is None + + def test_head_object_not_found_raises_object_not_found_error(self): + container = MagicMock() + blob_client = MagicMock() + blob_client.get_blob_properties.side_effect = ResourceNotFoundError("gone") + container.get_blob_client.return_value = blob_client + client = _make_client(container) + + with pytest.raises(ObjectNotFoundError): + client.head_object("missing.txt") + + def test_head_object_empty_name_raises(self): + client = _make_client(MagicMock()) + with pytest.raises(ValueError, match="name must be a non-empty string"): + client.head_object("") + + +class TestCreateContainerClient: + """Test AzureClient._create_container_client in isolation (mirrors TestCreateStorageClient in GCS).""" + + def test_build_container_client_calls_from_container_url_with_correct_args(self): + from unittest.mock import sentinel + + cfg = AzureConfig( + container_name="container", + container_uri="https://account.blob.core.windows.net/container", + sas_token="sv=2020", + ) + + with patch( + "azure.storage.blob.ContainerClient.from_container_url", + return_value=sentinel.container_client, + ) as mock_from_url: + instance = object.__new__(AzureClient) + result = instance._create_container_client(cfg) + + mock_from_url.assert_called_once_with( + cfg.container_uri, credential=cfg.sas_token + ) + assert result is sentinel.container_client + + def test_import_error_raises_client_creation_error(self): + from sap_cloud_sdk.objectstore.exceptions import ClientCreationError + + cfg = AzureConfig( + container_name="container", + container_uri="https://account.blob.core.windows.net/container", + sas_token="sv=2020", + ) + + # Simulate the ImportError branch by patching ContainerClient.from_container_url + # to raise ImportError (which the method catches and re-wraps). + with patch( + "azure.storage.blob.ContainerClient.from_container_url", + side_effect=ImportError("azure-storage-blob not installed"), + ): + instance = object.__new__(AzureClient) + with pytest.raises(ClientCreationError, match="sap-cloud-sdk\\[azure\\]"): + instance._create_container_client(cfg) + + +class TestAzureClientObjectExists: + + def test_object_exists_returns_true_when_present(self): + container = MagicMock() + blob_client = MagicMock() + props = MagicMock() + props.last_modified = datetime(2023, 1, 1) + props.etag = "etag" + props.size = 1 + props.blob_tier = None + blob_client.get_blob_properties.return_value = props + container.get_blob_client.return_value = blob_client + client = _make_client(container) + + assert client.object_exists("file.txt") is True + + def test_object_exists_returns_false_when_not_found(self): + container = MagicMock() + blob_client = MagicMock() + blob_client.get_blob_properties.side_effect = ResourceNotFoundError("gone") + container.get_blob_client.return_value = blob_client + client = _make_client(container) + + assert client.object_exists("missing.txt") is False + + def test_object_exists_empty_name_raises(self): + client = _make_client(MagicMock()) + with pytest.raises(ValueError, match="name must be a non-empty string"): + client.object_exists("") diff --git a/tests/objectstore/unit/test_config.py b/tests/objectstore/unit/test_config.py new file mode 100644 index 00000000..76cb92a3 --- /dev/null +++ b/tests/objectstore/unit/test_config.py @@ -0,0 +1,324 @@ +"""Tests for objectstore config binding-data and load functions.""" + +from unittest.mock import MagicMock, patch + +import pytest + +from sap_cloud_sdk.objectstore._models import ObjectStoreProvider +from sap_cloud_sdk.objectstore.config import ( + AzureBindingData, + AzureConfig, + GcsBindingData, + GcsConfig, + S3BindingData, + S3Config, + load_from_env_or_mount, +) +from sap_cloud_sdk.objectstore.exceptions import ConfigError + + +class TestS3BindingDataValidate: + + def test_validate_raises_when_all_required_fields_empty(self): + binding = S3BindingData() + with pytest.raises(ConfigError, match="s3 binding is missing required field"): + binding.validate() + + def test_validate_raises_and_names_each_missing_field(self): + binding = S3BindingData(access_key_id="", secret_access_key="", bucket="", host="") + with pytest.raises(ConfigError) as exc_info: + binding.validate() + msg = str(exc_info.value) + assert "access_key_id" in msg + assert "secret_access_key" in msg + assert "bucket" in msg + assert "host" in msg + + def test_validate_raises_naming_only_the_missing_field(self): + binding = S3BindingData( + access_key_id="key", secret_access_key="secret", bucket="", host="host" + ) + with pytest.raises(ConfigError) as exc_info: + binding.validate() + msg = str(exc_info.value) + assert "bucket" in msg + assert "access_key_id" not in msg + assert "secret_access_key" not in msg + assert "host" not in msg + + def test_validate_does_not_raise_when_all_required_fields_populated(self): + binding = S3BindingData( + access_key_id="key", + secret_access_key="secret", + bucket="my-bucket", + host="s3.example.com", + ) + binding.validate() # must not raise + + +class TestS3BindingDataToConfig: + + def test_to_config_maps_fields_onto_s3_config(self): + binding = S3BindingData( + access_key_id="key", + secret_access_key="secret", + bucket="my-bucket", + host="s3.example.com", + ) + cfg = binding.to_config() + assert isinstance(cfg, S3Config) + assert cfg.access_key_id == "key" + assert cfg.secret_access_key == "secret" + assert cfg.bucket == "my-bucket" + assert cfg.host == "s3.example.com" + + def test_to_config_defaults_disable_ssl_to_false(self): + binding = S3BindingData( + access_key_id="k", secret_access_key="s", bucket="b", host="h" + ) + cfg = binding.to_config() + assert cfg.disable_ssl is False + + def test_to_config_sets_disable_ssl_true_when_passed(self): + binding = S3BindingData( + access_key_id="k", secret_access_key="s", bucket="b", host="h" + ) + cfg = binding.to_config(disable_ssl=True) + assert cfg.disable_ssl is True + + +class TestAzureBindingDataValidate: + + def test_validate_raises_when_all_required_fields_empty(self): + binding = AzureBindingData() + with pytest.raises(ConfigError, match="azure binding is missing required field"): + binding.validate() + + def test_validate_raises_and_names_each_missing_field(self): + binding = AzureBindingData(container_name="", container_uri="", sas_token="") + with pytest.raises(ConfigError) as exc_info: + binding.validate() + msg = str(exc_info.value) + assert "container_name" in msg + assert "container_uri" in msg + assert "sas_token" in msg + + def test_validate_raises_naming_only_the_missing_field(self): + binding = AzureBindingData( + container_name="container", + container_uri="https://example.com/c", + sas_token="", + ) + with pytest.raises(ConfigError) as exc_info: + binding.validate() + msg = str(exc_info.value) + assert "sas_token" in msg + assert "container_uri" not in msg + + def test_validate_does_not_raise_when_required_fields_populated(self): + binding = AzureBindingData( + container_name="container", + container_uri="https://account.blob.core.windows.net/container", + sas_token="sv=2020", + ) + binding.validate() # must not raise + + +class TestAzureBindingDataToConfig: + + def test_to_config_maps_fields_onto_azure_config(self): + binding = AzureBindingData( + container_name="container", + container_uri="https://account.blob.core.windows.net/container", + sas_token="sv=2020", + ) + cfg = binding.to_config() + assert isinstance(cfg, AzureConfig) + assert cfg.container_name == "container" + assert cfg.container_uri == "https://account.blob.core.windows.net/container" + assert cfg.sas_token == "sv=2020" + + +class TestGcsBindingDataValidate: + + def test_validate_raises_when_all_required_fields_empty(self): + binding = GcsBindingData() + with pytest.raises(ConfigError, match="gcs binding is missing required field"): + binding.validate() + + def test_validate_raises_and_names_each_missing_field(self): + binding = GcsBindingData( + base64EncodedPrivateKeyData="", projectId="", bucket="" + ) + with pytest.raises(ConfigError) as exc_info: + binding.validate() + msg = str(exc_info.value) + assert "base64EncodedPrivateKeyData" in msg + assert "projectId" in msg + assert "bucket" in msg + + def test_validate_raises_naming_only_the_missing_field(self): + binding = GcsBindingData( + base64EncodedPrivateKeyData="data", projectId="proj", bucket="" + ) + with pytest.raises(ConfigError) as exc_info: + binding.validate() + msg = str(exc_info.value) + assert "bucket" in msg + assert "base64EncodedPrivateKeyData" not in msg + assert "projectId" not in msg + + def test_validate_does_not_raise_when_all_required_fields_populated(self): + binding = GcsBindingData( + base64EncodedPrivateKeyData="dGVzdA==", + projectId="my-project", + bucket="my-bucket", + ) + binding.validate() # must not raise + + +class TestGcsBindingDataToConfig: + + def test_to_config_maps_camel_case_to_snake_case(self): + binding = GcsBindingData( + base64EncodedPrivateKeyData="dGVzdA==", + projectId="my-project", + bucket="my-bucket", + ) + cfg = binding.to_config() + assert isinstance(cfg, GcsConfig) + # camelCase binding attrs map to snake_case config fields + assert cfg.base64_encoded_private_key_data == "dGVzdA==" + assert cfg.project_id == "my-project" + assert cfg.bucket == "my-bucket" + + +_RESOLVER_PATH = "sap_cloud_sdk.objectstore.config.read_from_mount_and_fallback_to_env_var" + + +class TestLoadFromEnvOrMountS3: + + def test_returns_s3_config_for_s3_provider(self): + def populate_binding( + base_volume_mount, base_var_name, module, instance, target + ): + target.access_key_id = "key" + target.secret_access_key = "secret" + target.bucket = "bucket" + target.host = "host" + + with patch(_RESOLVER_PATH, side_effect=populate_binding): + cfg = load_from_env_or_mount(ObjectStoreProvider.S3, "default") + + assert isinstance(cfg, S3Config) + assert cfg.access_key_id == "key" + + def test_resolver_called_with_module_objectstore_and_instance(self): + def populate_binding(*, module, instance, target, **_): + target.access_key_id = "k" + target.secret_access_key = "s" + target.bucket = "b" + target.host = "h" + + mock_resolver = MagicMock(side_effect=populate_binding) + + with patch(_RESOLVER_PATH, mock_resolver): + load_from_env_or_mount(ObjectStoreProvider.S3, "my-instance") + + mock_resolver.assert_called_once() + kwargs = mock_resolver.call_args.kwargs + assert kwargs["module"] == "objectstore" + assert kwargs["instance"] == "my-instance" + + def test_resolver_exception_wrapped_in_config_error(self): + with patch(_RESOLVER_PATH, side_effect=RuntimeError("network error")): + with pytest.raises(ConfigError, match="my-instance"): + load_from_env_or_mount(ObjectStoreProvider.S3, "my-instance") + + def test_binding_validation_failure_raises_config_error(self): + # Resolver succeeds but leaves fields empty → validate() fails. + with patch(_RESOLVER_PATH): # no-op: leaves binding with empty strings + with pytest.raises(ConfigError, match="s3 binding is missing required field"): + load_from_env_or_mount(ObjectStoreProvider.S3, "default") + + +class TestLoadFromEnvOrMountAzure: + def test_loads_binding_without_unused_optional_fields(self, tmp_path, monkeypatch): + binding_dir = tmp_path / "objectstore" + binding_dir.mkdir() + (binding_dir / "container_uri").write_text( + "https://example.com/container", encoding="utf-8" + ) + (binding_dir / "container_name").write_text("container", encoding="utf-8") + (binding_dir / "sas_token").write_text("sv=2020", encoding="utf-8") + monkeypatch.setenv("SERVICE_BINDING_ROOT", str(tmp_path)) + + cfg = load_from_env_or_mount(ObjectStoreProvider.AZURE, "default") + + assert cfg == AzureConfig( + container_name="container", + container_uri="https://example.com/container", + sas_token="sv=2020", + ) + + def test_returns_azure_config_for_azure_provider(self): + def populate_binding( + base_volume_mount, base_var_name, module, instance, target + ): + target.container_uri = "https://example.com/c" + target.container_name = "container" + target.sas_token = "sv=2020" + + with patch(_RESOLVER_PATH, side_effect=populate_binding): + cfg = load_from_env_or_mount(ObjectStoreProvider.AZURE, "default") + + assert isinstance(cfg, AzureConfig) + assert cfg.sas_token == "sv=2020" + + def test_binding_validation_failure_raises_config_error_for_azure(self): + with patch(_RESOLVER_PATH): + with pytest.raises(ConfigError, match="azure binding is missing required field"): + load_from_env_or_mount(ObjectStoreProvider.AZURE, "default") + + +class TestLoadFromEnvOrMountGcs: + def test_loads_camel_case_keys_from_service_binding_mount( + self, tmp_path, monkeypatch + ): + binding_dir = tmp_path / "objectstore" + binding_dir.mkdir() + secrets = { + "base64EncodedPrivateKeyData": "dGVzdA==", + "projectId": "my-project", + "bucket": "my-bucket", + } + for name, value in secrets.items(): + (binding_dir / name).write_text(value, encoding="utf-8") + monkeypatch.setenv("SERVICE_BINDING_ROOT", str(tmp_path)) + + cfg = load_from_env_or_mount(ObjectStoreProvider.GCS, "default") + + assert isinstance(cfg, GcsConfig) + assert cfg.base64_encoded_private_key_data == "dGVzdA==" + assert cfg.project_id == "my-project" + assert cfg.bucket == "my-bucket" + + def test_returns_gcs_config_for_gcs_provider(self): + def populate_binding( + base_volume_mount, base_var_name, module, instance, target + ): + target.base64EncodedPrivateKeyData = "dGVzdA==" + target.projectId = "my-project" + target.bucket = "my-bucket" + + with patch(_RESOLVER_PATH, side_effect=populate_binding): + cfg = load_from_env_or_mount(ObjectStoreProvider.GCS, "default") + + assert isinstance(cfg, GcsConfig) + assert cfg.project_id == "my-project" + assert cfg.base64_encoded_private_key_data == "dGVzdA==" + + def test_binding_validation_failure_raises_config_error_for_gcs(self): + with patch(_RESOLVER_PATH): + with pytest.raises(ConfigError, match="gcs binding is missing required field"): + load_from_env_or_mount(ObjectStoreProvider.GCS, "default") diff --git a/tests/objectstore/unit/test_create_client.py b/tests/objectstore/unit/test_create_client.py index 979abbc5..ef62dbfd 100644 --- a/tests/objectstore/unit/test_create_client.py +++ b/tests/objectstore/unit/test_create_client.py @@ -1,55 +1,159 @@ """Tests for create_client factory function.""" -from unittest.mock import Mock, patch +from unittest.mock import MagicMock, patch import pytest from sap_cloud_sdk.objectstore import create_client -from sap_cloud_sdk.objectstore._models import ObjectStoreBindingData +from sap_cloud_sdk.objectstore.config import ( + AzureBindingData, + AzureConfig, + GcsBindingData, + GcsConfig, + S3BindingData, + S3Config, +) +from sap_cloud_sdk.objectstore.exceptions import ClientCreationError -class TestCreateClient: +class TestCreateClientValidation: - @patch('sap_cloud_sdk.objectstore.read_from_mount_and_fallback_to_env_var') - @patch('sap_cloud_sdk.objectstore.ObjectStoreClient') - def test_create_client_cloud_mode(self, mock_client_class, mock_resolver): - mock_client = Mock() - mock_client_class.return_value = mock_client - - result = create_client("production", disable_ssl=True) - - mock_resolver.assert_called_once() - call_args = mock_resolver.call_args - assert call_args[1]["module"] == "objectstore" - assert call_args[1]["instance"] == "production" - assert isinstance(call_args[1]["target"], ObjectStoreBindingData) - mock_client_class.assert_called_once_with(call_args[1]["target"], disable_ssl=True) - assert result == mock_client - - def test_create_client_empty_instance_raises_error(self): - """Test that create_client raises ValueError for empty instance.""" + def test_create_client_empty_instance_raises_value_error(self): with pytest.raises(ValueError, match="instance parameter must be a non-empty string"): create_client("") + def test_create_client_whitespace_only_instance_raises_value_error(self): with pytest.raises(ValueError, match="instance parameter must be a non-empty string"): - create_client(" ") # whitespace only + create_client(" ") + def test_create_client_none_instance_raises_value_error(self): with pytest.raises(ValueError, match="instance parameter must be a non-empty string"): create_client(None) # type: ignore - @patch('sap_cloud_sdk.objectstore.ObjectStoreClient') - def test_create_client_with_explicit_config(self, mock_client_class): - """Test that create_client uses explicit config when provided.""" - mock_config = ObjectStoreBindingData( - access_key_id="explicit_key", - secret_access_key="explicit_secret", - bucket="explicit-bucket", - host="explicit.host.com" +class TestCreateClientExplicitConfig: + + @patch("sap_cloud_sdk.objectstore._factory.S3Client") + def test_create_client_with_s3_config_returns_s3_client(self, mock_s3_class): + mock_instance = MagicMock() + mock_s3_class.return_value = mock_instance + config = S3Config( + access_key_id="key", + secret_access_key="secret", + bucket="bucket", + host="s3.example.com", + ) + + result = create_client("any-instance", config=config) + + mock_s3_class.assert_called_once_with(config) + assert result is mock_instance + + @patch("sap_cloud_sdk.objectstore._factory.AzureClient") + def test_create_client_with_azure_config_returns_azure_client(self, mock_azure_class): + mock_instance = MagicMock() + mock_azure_class.return_value = mock_instance + config = AzureConfig( + container_name="container", + container_uri="https://account.blob.core.windows.net/container", + sas_token="sv=...", ) - mock_client = Mock() - mock_client_class.return_value = mock_client - result = create_client("ignored-instance", config=mock_config, disable_ssl=True) + result = create_client("any-instance", config=config) + + mock_azure_class.assert_called_once_with(config) + assert result is mock_instance + + @patch("sap_cloud_sdk.objectstore._factory.GcsClient") + def test_create_client_with_gcs_config_returns_gcs_client(self, mock_gcs_class): + mock_instance = MagicMock() + mock_gcs_class.return_value = mock_instance + config = GcsConfig( + base64_encoded_private_key_data="dGVzdA==", + project_id="my-project", + bucket="my-bucket", + ) + + result = create_client("any-instance", config=config) + + mock_gcs_class.assert_called_once_with(config) + assert result is mock_instance + + def test_create_client_with_unknown_config_type_raises_client_creation_error(self): + with pytest.raises(ClientCreationError, match="Unsupported config type"): + create_client("any-instance", config=object()) # type: ignore + + +class TestCreateClientAutoDetection: + + @patch("sap_cloud_sdk.objectstore._factory.S3Client") + @patch("sap_cloud_sdk.objectstore._factory.load_from_env_or_mount") + @patch("sap_cloud_sdk.objectstore._factory.read_binding_keys") + def test_create_client_autodetects_s3( + self, mock_read_keys, mock_load, mock_s3_class + ): + s3_keys = {"access_key_id", "secret_access_key", "host", "bucket"} + mock_read_keys.return_value = s3_keys + mock_config = S3Config( + access_key_id="", secret_access_key="", bucket="", host="" + ) + mock_load.return_value = mock_config + mock_instance = MagicMock() + mock_s3_class.return_value = mock_instance + + result = create_client("default") + + mock_read_keys.assert_called_once_with("default") + mock_load.assert_called_once_with("s3", "default") + mock_s3_class.assert_called_once_with(mock_config) + assert result is mock_instance + + @patch("sap_cloud_sdk.objectstore._factory.AzureClient") + @patch("sap_cloud_sdk.objectstore._factory.load_from_env_or_mount") + @patch("sap_cloud_sdk.objectstore._factory.read_binding_keys") + def test_create_client_autodetects_azure( + self, mock_read_keys, mock_load, mock_azure_class + ): + azure_keys = {"container_uri", "sas_token", "container_name"} + mock_read_keys.return_value = azure_keys + mock_config = AzureConfig( + container_name="", container_uri="", sas_token="" + ) + mock_load.return_value = mock_config + mock_instance = MagicMock() + mock_azure_class.return_value = mock_instance + + result = create_client("my-azure-instance") + + mock_load.assert_called_once_with("azure", "my-azure-instance") + mock_azure_class.assert_called_once_with(mock_config) + assert result is mock_instance + + @patch("sap_cloud_sdk.objectstore._factory.GcsClient") + @patch("sap_cloud_sdk.objectstore._factory.load_from_env_or_mount") + @patch("sap_cloud_sdk.objectstore._factory.read_binding_keys") + def test_create_client_autodetects_gcs( + self, mock_read_keys, mock_load, mock_gcs_class + ): + gcs_keys = {"base64encodedprivatekeydata", "projectid", "bucket", "region"} + mock_read_keys.return_value = gcs_keys + mock_config = GcsConfig( + base64_encoded_private_key_data="", project_id="", bucket="" + ) + mock_load.return_value = mock_config + mock_instance = MagicMock() + mock_gcs_class.return_value = mock_instance + + result = create_client("my-gcs-instance") + + mock_load.assert_called_once_with("gcs", "my-gcs-instance") + mock_gcs_class.assert_called_once_with(mock_config) + assert result is mock_instance + + @patch("sap_cloud_sdk.objectstore._factory.read_binding_keys") + def test_create_client_no_matching_provider_raises_client_creation_error( + self, mock_read_keys + ): + mock_read_keys.return_value = {"garbage", "unknown_key"} - mock_client_class.assert_called_once_with(mock_config, disable_ssl=True) - assert result == mock_client + with pytest.raises(ClientCreationError): + create_client("unknown-instance") diff --git a/tests/objectstore/unit/test_detect.py b/tests/objectstore/unit/test_detect.py new file mode 100644 index 00000000..f9469f5d --- /dev/null +++ b/tests/objectstore/unit/test_detect.py @@ -0,0 +1,210 @@ +"""Tests for provider auto-detection logic.""" + +import os +from itertools import combinations +from unittest.mock import patch + +import pytest + +from sap_cloud_sdk.objectstore._detect import ( + _DISCRIMINATORS, + detect_provider, + read_binding_keys, +) + + +class TestDetectProvider: + + def test_s3_keys_detected_as_s3(self): + keys = {"access_key_id", "secret_access_key", "host", "bucket", "region"} + assert detect_provider(keys) == "s3" + + def test_azure_keys_detected_as_azure(self): + keys = {"container_uri", "sas_token", "container_name"} + assert detect_provider(keys) == "azure" + + def test_gcs_keys_detected_as_gcs(self): + # Real binding key names are camelCase (as they appear in the mount). + keys = {"base64EncodedPrivateKeyData", "projectId", "bucket", "region"} + assert detect_provider(keys) == "gcs" + + def test_gcs_wins_over_s3_when_gcs_discriminators_present(self): + # GCS and S3 share 'bucket'/'region'; if GCS discriminators are present + # it must be detected as GCS, not S3. + keys = { + "base64EncodedPrivateKeyData", + "projectId", + "bucket", + "region", + } + assert detect_provider(keys) == "gcs" + + def test_empty_keys_raises_value_error(self): + with pytest.raises(ValueError, match="Cannot detect objectstore provider"): + detect_provider(set()) + + def test_unrecognised_keys_raises_value_error(self): + with pytest.raises(ValueError, match="Cannot detect objectstore provider"): + detect_provider({"garbage", "unknown_key"}) + + def test_mixed_non_matching_keys_raises_value_error(self): + # Partial overlap with S3 but missing 'host' + with pytest.raises(ValueError): + detect_provider({"access_key_id", "secret_access_key"}) + + def test_uppercase_keys_still_detected_as_s3(self): + """detect_provider must lowercase before matching.""" + keys = {"ACCESS_KEY_ID", "SECRET_ACCESS_KEY", "HOST"} + assert detect_provider(keys) == "s3" + + def test_uppercase_azure_keys_still_detected(self): + keys = {"CONTAINER_URI", "SAS_TOKEN", "CONTAINER_NAME"} + assert detect_provider(keys) == "azure" + + def test_uppercase_gcs_keys_still_detected(self): + # Env-var form: keys arrive fully uppercased. + keys = {"BASE64ENCODEDPRIVATEKEYDATA", "PROJECTID"} + assert detect_provider(keys) == "gcs" + + def test_camelcase_gcs_keys_detected(self): + # Mount form: keys arrive verbatim in their binding camelCase. + keys = {"base64EncodedPrivateKeyData", "projectId"} + assert detect_provider(keys) == "gcs" + + def test_azure_wins_before_gcs_and_s3(self): + """The azure discriminator set must be checked first.""" + # Artificially include both azure + gcs discriminators to verify ordering. + keys = { + "container_uri", + "sas_token", + "container_name", + "base64EncodedPrivateKeyData", + "projectId", + } + assert detect_provider(keys) == "azure" + + def test_discriminator_sets_are_pairwise_disjoint(self): + """Each provider must own at least one key no other provider shares. + + Detection is first-match-wins; it is only unambiguous while the + discriminator sets don't overlap. If a new provider is added with a + discriminator that intersects an existing one, first-match ordering + would silently pick the wrong provider — this test fails loudly instead. + """ + lowered = { + provider: {d.lower() for d in discriminators} + for provider, discriminators in _DISCRIMINATORS.items() + } + for (p_a, set_a), (p_b, set_b) in combinations(lowered.items(), 2): + overlap = set_a & set_b + assert not overlap, ( + f"discriminators for {p_a} and {p_b} overlap on {sorted(overlap)}; " + "detection can no longer distinguish these providers" + ) + + +class TestReadBindingKeysLegacyLayout: + + def test_legacy_layout_returns_verbatim_file_names(self, tmp_path, monkeypatch): + """Files under {base}/objectstore/{instance}/ are returned verbatim (no case-folding).""" + instance = "default" + legacy_dir = tmp_path / "objectstore" / instance + legacy_dir.mkdir(parents=True) + (legacy_dir / "access_key_id").touch() + (legacy_dir / "SECRET_ACCESS_KEY").touch() + (legacy_dir / "HOST").touch() + + # Patch resolve_base_mount to return our tmp dir as the base. + monkeypatch.delenv("SERVICE_BINDING_ROOT", raising=False) + monkeypatch.setattr( + "sap_cloud_sdk.objectstore._detect.resolve_base_mount", + lambda *a, **kw: str(tmp_path), + ) + + keys = read_binding_keys(instance) + + # Keys are returned as-is (verbatim filenames); no lowercasing here. + assert "access_key_id" in keys + assert "SECRET_ACCESS_KEY" in keys + assert "HOST" in keys + + def test_legacy_layout_missing_dir_returns_empty(self, tmp_path, monkeypatch): + monkeypatch.delenv("SERVICE_BINDING_ROOT", raising=False) + monkeypatch.setattr( + "sap_cloud_sdk.objectstore._detect.resolve_base_mount", + lambda *a, **kw: str(tmp_path), + ) + keys = read_binding_keys("nonexistent-instance") + assert keys == set() + + +class TestReadBindingKeysFlatLayout: + + def test_flat_layout_returns_file_names(self, tmp_path, monkeypatch): + """Files under $SERVICE_BINDING_ROOT/objectstore/ are returned as keys.""" + flat_dir = tmp_path / "objectstore" + flat_dir.mkdir(parents=True) + (flat_dir / "container_uri").touch() + (flat_dir / "sas_token").touch() + (flat_dir / "container_name").touch() + + monkeypatch.setenv("SERVICE_BINDING_ROOT", str(tmp_path)) + monkeypatch.setattr( + "sap_cloud_sdk.objectstore._detect.resolve_base_mount", + lambda *a, **kw: str(tmp_path), + ) + + keys = read_binding_keys("any-instance") + + assert "container_uri" in keys + assert "sas_token" in keys + assert "container_name" in keys + + +class TestReadBindingKeysEnvLayout: + + def test_env_vars_stripped_verbatim(self, monkeypatch, tmp_path): + """CLOUD_SDK_CFG_OBJECTSTORE_{INSTANCE}_* env vars are picked up; suffix is returned as-is (uppercase).""" + monkeypatch.delenv("SERVICE_BINDING_ROOT", raising=False) + monkeypatch.setattr( + "sap_cloud_sdk.objectstore._detect.resolve_base_mount", + lambda *a, **kw: str(tmp_path), + ) + monkeypatch.setenv("CLOUD_SDK_CFG_OBJECTSTORE_DEFAULT_ACCESS_KEY_ID", "k") + monkeypatch.setenv("CLOUD_SDK_CFG_OBJECTSTORE_DEFAULT_SECRET_ACCESS_KEY", "s") + monkeypatch.setenv("CLOUD_SDK_CFG_OBJECTSTORE_DEFAULT_HOST", "h") + + keys = read_binding_keys("default") + + # The prefix is stripped; the remaining suffix is returned as-is (uppercase). + assert "ACCESS_KEY_ID" in keys + assert "SECRET_ACCESS_KEY" in keys + assert "HOST" in keys + + def test_env_vars_with_hyphens_in_instance_name(self, monkeypatch, tmp_path): + """Hyphens in instance names become underscores in the env prefix.""" + monkeypatch.delenv("SERVICE_BINDING_ROOT", raising=False) + monkeypatch.setattr( + "sap_cloud_sdk.objectstore._detect.resolve_base_mount", + lambda *a, **kw: str(tmp_path), + ) + monkeypatch.setenv( + "CLOUD_SDK_CFG_OBJECTSTORE_MY_INSTANCE_ACCESS_KEY_ID", "val" + ) + + keys = read_binding_keys("my-instance") + + # The suffix after the prefix is returned verbatim (uppercase). + assert "ACCESS_KEY_ID" in keys + + def test_env_vars_different_instance_not_picked_up(self, monkeypatch, tmp_path): + monkeypatch.delenv("SERVICE_BINDING_ROOT", raising=False) + monkeypatch.setattr( + "sap_cloud_sdk.objectstore._detect.resolve_base_mount", + lambda *a, **kw: str(tmp_path), + ) + monkeypatch.setenv("CLOUD_SDK_CFG_OBJECTSTORE_OTHER_HOST", "h") + + keys = read_binding_keys("default") + + assert "host" not in keys diff --git a/tests/objectstore/unit/test_gcs_client.py b/tests/objectstore/unit/test_gcs_client.py new file mode 100644 index 00000000..1e605122 --- /dev/null +++ b/tests/objectstore/unit/test_gcs_client.py @@ -0,0 +1,299 @@ +"""Tests for GcsClient object store backend.""" + +import base64 +import io +import json +from datetime import datetime +from unittest.mock import MagicMock, patch + +import pytest + +pytest.importorskip("google.cloud.storage") + +from google.cloud.exceptions import NotFound # noqa: E402 + +from sap_cloud_sdk.objectstore._gcs import GcsClient # noqa: E402 +from sap_cloud_sdk.objectstore.config import GcsConfig # noqa: E402 +from sap_cloud_sdk.objectstore.exceptions import ( # noqa: E402 + ListObjectsError, + ObjectNotFoundError, + ObjectOperationError, +) + +_CREDS = GcsConfig( + base64_encoded_private_key_data="dGVzdA==", + project_id="my-project", + bucket="my-bucket", +) + + +def _make_client(): + """Return (GcsClient, mock_gcs_client, mock_bucket) with patched builder.""" + mock_gcs = MagicMock() + mock_bucket = MagicMock() + mock_gcs.bucket.return_value = mock_bucket + with patch.object(GcsClient, "_create_storage_client", return_value=mock_gcs): + client = GcsClient(_CREDS) + return client, mock_gcs, mock_bucket + + +class TestGcsClientPutObjectFromBytes: + + def test_put_object_from_bytes_happy_path(self): + client, _, bucket = _make_client() + mock_blob = MagicMock() + bucket.blob.return_value = mock_blob + + client.put_object_from_bytes("test.txt", b"hello", "text/plain") + + bucket.blob.assert_called_with("test.txt") + mock_blob.upload_from_string.assert_called_once_with( + b"hello", content_type="text/plain" + ) + + def test_put_object_from_bytes_empty_name_raises(self): + client, _, _ = _make_client() + with pytest.raises(ValueError, match="name must be a non-empty string"): + client.put_object_from_bytes("", b"data", "text/plain") + + def test_put_object_from_bytes_non_bytes_raises(self): + client, _, _ = _make_client() + with pytest.raises(ValueError, match="data must be bytes"): + client.put_object_from_bytes("key", "not bytes", "text/plain") + + def test_put_object_from_bytes_empty_content_type_raises(self): + client, _, _ = _make_client() + with pytest.raises(ValueError, match="content_type must be a non-empty string"): + client.put_object_from_bytes("key", b"data", "") + + +class TestGcsClientPutObject: + + def test_put_object_happy_path(self): + client, _, bucket = _make_client() + mock_blob = MagicMock() + bucket.blob.return_value = mock_blob + stream = io.BytesIO(b"data") + + client.put_object("test.txt", stream, 4, "application/octet-stream") + + mock_blob.upload_from_file.assert_called_once_with( + stream, size=4, content_type="application/octet-stream" + ) + + def test_put_object_negative_size_raises(self): + client, _, _ = _make_client() + with pytest.raises(ValueError, match="size must be non-negative"): + client.put_object("key", io.BytesIO(b""), -1, "text/plain") + + def test_put_object_invalid_stream_raises(self): + client, _, _ = _make_client() + with pytest.raises(ValueError, match="stream must be"): + client.put_object("key", "not-a-stream", 0, "text/plain") + + +class TestGcsClientPutObjectFromFile: + + def test_put_object_from_file_empty_name_raises(self): + client, _, _ = _make_client() + with pytest.raises(ValueError, match="name must be a non-empty string"): + client.put_object_from_file("", "/path/to/file.txt", "text/plain") + + def test_put_object_from_file_empty_path_raises(self): + client, _, _ = _make_client() + with pytest.raises(ValueError, match="file_path must be a non-empty string"): + client.put_object_from_file("key", "", "text/plain") + + def test_put_object_from_file_missing_file_raises(self): + client, _, bucket = _make_client() + bucket.blob.return_value = MagicMock() + with pytest.raises(ObjectOperationError, match="File not found"): + client.put_object_from_file("key", "/nonexistent/path.txt", "text/plain") + + +class TestGcsClientGetObject: + + def test_get_object_happy_path(self): + client, _, bucket = _make_client() + mock_blob = MagicMock() + mock_stream = MagicMock() + mock_blob.open.return_value = mock_stream + bucket.blob.return_value = mock_blob + + result = client.get_object("test.txt") + + mock_blob.reload.assert_called_once() + mock_blob.open.assert_called_once_with("rb") + assert result is mock_stream + + def test_get_object_not_found_raises_object_not_found_error(self): + client, _, bucket = _make_client() + mock_blob = MagicMock() + mock_blob.reload.side_effect = NotFound("not found") + bucket.blob.return_value = mock_blob + + with pytest.raises(ObjectNotFoundError, match="Object 'missing.txt' not found"): + client.get_object("missing.txt") + + def test_get_object_empty_name_raises(self): + client, _, _ = _make_client() + with pytest.raises(ValueError, match="name must be a non-empty string"): + client.get_object("") + + +class TestGcsClientDeleteObject: + + def test_delete_object_happy_path(self): + client, _, bucket = _make_client() + mock_blob = MagicMock() + bucket.blob.return_value = mock_blob + + client.delete_object("test.txt") + + mock_blob.delete.assert_called_once() + + def test_delete_object_idempotent_swallows_not_found(self): + client, _, bucket = _make_client() + mock_blob = MagicMock() + mock_blob.delete.side_effect = NotFound("gone") + bucket.blob.return_value = mock_blob + + # Should not raise + client.delete_object("test.txt") + + def test_delete_object_empty_name_raises(self): + client, _, _ = _make_client() + with pytest.raises(ValueError, match="name must be a non-empty string"): + client.delete_object("") + + +class TestGcsClientListObjects: + + def test_list_objects_happy_path(self): + client, gcs_client, bucket = _make_client() + mock_blob = MagicMock() + mock_blob.name = "prefix/file1.txt" + mock_blob.updated = datetime(2023, 1, 1) + mock_blob.etag = '"abc123"' + mock_blob.size = 200 + mock_blob.storage_class = "STANDARD" + gcs_client.list_blobs.return_value = [mock_blob] + + result = client.list_objects("prefix/") + + gcs_client.list_blobs.assert_called_once_with(bucket, prefix="prefix/") + assert len(result) == 1 + assert result[0].key == "prefix/file1.txt" + assert result[0].etag == "abc123" # quotes stripped + assert result[0].size == 200 + assert result[0].storage_class == "STANDARD" + assert result[0].owner is None + + def test_list_objects_empty_prefix_allowed(self): + client, gcs_client, bucket = _make_client() + gcs_client.list_blobs.return_value = [] + result = client.list_objects("") + assert result == [] + + def test_list_objects_invalid_prefix_type_raises(self): + client, _, _ = _make_client() + with pytest.raises(ValueError, match="prefix must be a string"): + client.list_objects(42) + + +class TestGcsClientHeadObject: + + def test_head_object_happy_path(self): + client, _, bucket = _make_client() + mock_blob = MagicMock() + mock_blob.name = "file.txt" + mock_blob.updated = datetime(2023, 6, 15) + mock_blob.etag = '"etag99"' + mock_blob.size = 1024 + mock_blob.storage_class = "NEARLINE" + bucket.get_blob.return_value = mock_blob + + result = client.head_object("file.txt") + + bucket.get_blob.assert_called_once_with("file.txt") + assert result.key == "file.txt" + assert result.etag == "etag99" + assert result.size == 1024 + assert result.storage_class == "NEARLINE" + assert result.owner is None + + def test_head_object_none_blob_raises_object_not_found_error(self): + client, _, bucket = _make_client() + bucket.get_blob.return_value = None + + with pytest.raises(ObjectNotFoundError, match="Object 'missing.txt' not found"): + client.head_object("missing.txt") + + def test_head_object_empty_name_raises(self): + client, _, _ = _make_client() + with pytest.raises(ValueError, match="name must be a non-empty string"): + client.head_object("") + + +class TestGcsClientObjectExists: + + def test_object_exists_returns_true_when_present(self): + client, _, bucket = _make_client() + mock_blob = MagicMock() + mock_blob.name = "file.txt" + mock_blob.updated = datetime(2023, 1, 1) + mock_blob.etag = "etag" + mock_blob.size = 1 + mock_blob.storage_class = None + bucket.get_blob.return_value = mock_blob + + assert client.object_exists("file.txt") is True + + def test_object_exists_returns_false_when_not_found(self): + client, _, bucket = _make_client() + bucket.get_blob.return_value = None + + assert client.object_exists("missing.txt") is False + + def test_object_exists_empty_name_raises(self): + client, _, _ = _make_client() + with pytest.raises(ValueError, match="name must be a non-empty string"): + client.object_exists("") + + +class TestCreateStorageClient: + """Test the GcsClient._create_storage_client credential transform in isolation.""" + + def test_build_gcs_client_decodes_base64_and_passes_to_credentials(self): + service_account_info = { + "type": "service_account", + "project_id": "my-project", + "private_key_id": "key-id", + } + encoded = base64.b64encode( + json.dumps(service_account_info).encode() + ).decode() + cfg = GcsConfig( + base64_encoded_private_key_data=encoded, + project_id="my-project", + bucket="my-bucket", + ) + + mock_creds = MagicMock() + mock_storage_client = MagicMock() + + with patch( + "google.oauth2.service_account.Credentials.from_service_account_info", + return_value=mock_creds, + ) as mock_from_info, patch( + "google.cloud.storage.Client", + return_value=mock_storage_client, + ) as mock_client_class: + instance = object.__new__(GcsClient) + result = instance._create_storage_client(cfg) + + mock_from_info.assert_called_once_with(service_account_info) + mock_client_class.assert_called_once_with( + project="my-project", credentials=mock_creds + ) + assert result is mock_storage_client diff --git a/tests/objectstore/unit/test_models.py b/tests/objectstore/unit/test_models.py index efb75a46..9aa26aee 100644 --- a/tests/objectstore/unit/test_models.py +++ b/tests/objectstore/unit/test_models.py @@ -1,26 +1,33 @@ """Tests for data models.""" +from dataclasses import is_dataclass from datetime import datetime + import pytest -from sap_cloud_sdk.objectstore._models import ObjectStoreBindingData, ObjectMetadata +from sap_cloud_sdk.objectstore._models import ObjectMetadata +from sap_cloud_sdk.objectstore.config import ( + AzureBindingData, + GcsBindingData, + S3BindingData, +) -class TestObjectStoreBindingData: +class TestS3BindingData: def test_empty_initialization(self): - config = ObjectStoreBindingData() + config = S3BindingData() assert config.access_key_id == "" assert config.secret_access_key == "" assert config.bucket == "" assert config.host == "" def test_field_assignment(self): - config = ObjectStoreBindingData( + config = S3BindingData( access_key_id="test_key", secret_access_key="test_secret", bucket="test-bucket", - host="localhost:9000" + host="localhost:9000", ) assert config.access_key_id == "test_key" assert config.secret_access_key == "test_secret" @@ -28,17 +35,62 @@ def test_field_assignment(self): assert config.host == "localhost:9000" def test_is_dataclass(self): - from dataclasses import is_dataclass - assert is_dataclass(ObjectStoreBindingData) + assert is_dataclass(S3BindingData) def test_mutable_fields(self): - config = ObjectStoreBindingData() + config = S3BindingData() config.access_key_id = "new_key" config.secret_access_key = "new_secret" assert config.access_key_id == "new_key" assert config.secret_access_key == "new_secret" +class TestAzureBindingData: + + def test_empty_initialization(self): + config = AzureBindingData() + assert config.container_name == "" + assert config.container_uri == "" + assert config.sas_token == "" + + def test_field_assignment(self): + config = AzureBindingData( + container_name="mycontainer", + container_uri="https://myaccount.blob.core.windows.net/mycontainer", + sas_token="sv=2020-08-04&ss=b", + ) + assert config.container_name == "mycontainer" + assert config.container_uri == ( + "https://myaccount.blob.core.windows.net/mycontainer" + ) + assert config.sas_token == "sv=2020-08-04&ss=b" + + def test_is_dataclass(self): + assert is_dataclass(AzureBindingData) + + +class TestGcsBindingData: + + def test_empty_initialization(self): + config = GcsBindingData() + assert config.base64EncodedPrivateKeyData == "" + assert config.projectId == "" + assert config.bucket == "" + + def test_field_assignment(self): + config = GcsBindingData( + base64EncodedPrivateKeyData="dGVzdA==", + projectId="my-gcp-project", + bucket="my-gcs-bucket", + ) + assert config.base64EncodedPrivateKeyData == "dGVzdA==" + assert config.projectId == "my-gcp-project" + assert config.bucket == "my-gcs-bucket" + + def test_is_dataclass(self): + assert is_dataclass(GcsBindingData) + + class TestObjectMetadata: def test_creation_all_fields(self): @@ -49,7 +101,7 @@ def test_creation_all_fields(self): etag="abc123", size=100, storage_class="STANDARD", - owner="test_owner" + owner="test_owner", ) assert metadata.key == "test.txt" assert metadata.last_modified == test_time @@ -64,7 +116,7 @@ def test_creation_optional_fields_none(self): key="test.txt", last_modified=test_time, etag="abc123", - size=100 + size=100, ) assert metadata.storage_class is None assert metadata.owner is None @@ -75,14 +127,12 @@ def test_frozen_dataclass(self): key="test.txt", last_modified=test_time, etag="abc123", - size=100 + size=100, ) - with pytest.raises(AttributeError): metadata.key = "new_key" # ty: ignore[invalid-assignment] def test_is_frozen_dataclass(self): - from dataclasses import is_dataclass assert is_dataclass(ObjectMetadata) test_time = datetime(2023, 1, 1, 12, 0, 0) @@ -90,6 +140,6 @@ def test_is_frozen_dataclass(self): key="test.txt", last_modified=test_time, etag="abc123", - size=100 + size=100, ) assert metadata.__dataclass_params__.frozen is True diff --git a/tests/objectstore/unit/test_protocol.py b/tests/objectstore/unit/test_protocol.py new file mode 100644 index 00000000..a837f9d2 --- /dev/null +++ b/tests/objectstore/unit/test_protocol.py @@ -0,0 +1,15 @@ +"""Static contract examples for the object store protocols.""" + +from sap_cloud_sdk.objectstore import ObjectStoreClient + + +def _read_object_with_managed_lifecycle(client: ObjectStoreClient) -> bytes: + """Exercise the public reader contract for static type checking.""" + with client.get_object("object.bin") as reader: + return reader.read() + + +def _close_object_reader_explicitly(client: ObjectStoreClient) -> None: + """Ensure explicit cleanup is also part of the public contract.""" + reader = client.get_object("object.bin") + reader.close() diff --git a/tests/objectstore/unit/test_s3_client.py b/tests/objectstore/unit/test_s3_client.py index fec111f1..f21cf70f 100644 --- a/tests/objectstore/unit/test_s3_client.py +++ b/tests/objectstore/unit/test_s3_client.py @@ -8,21 +8,22 @@ import pytest from minio.error import S3Error -from sap_cloud_sdk.objectstore._s3 import ObjectStoreClient -from sap_cloud_sdk.objectstore._models import ObjectStoreBindingData, ObjectMetadata +from sap_cloud_sdk.objectstore._s3 import S3Client +from sap_cloud_sdk.objectstore._models import ObjectMetadata +from sap_cloud_sdk.objectstore.config import S3Config from sap_cloud_sdk.objectstore.exceptions import ( ClientCreationError, ObjectOperationError, ObjectNotFoundError, ListObjectsError ) -class TestObjectStoreClient: +class TestS3Client: def setup_method(self): - self.creds = ObjectStoreBindingData( + self.config = S3Config( access_key_id="test_key", secret_access_key="test_secret", bucket="test-bucket", - host="s3.amazonaws.com" + host="s3.amazonaws.com", ) @patch('sap_cloud_sdk.objectstore._s3.Minio') @@ -30,7 +31,7 @@ def test_client_creation_ssl_enabled(self, mock_minio_class): mock_minio = Mock() mock_minio_class.return_value = mock_minio - client = ObjectStoreClient(self.creds, disable_ssl=False) + client = S3Client(self.config) mock_minio_class.assert_called_once_with( endpoint="s3.amazonaws.com", @@ -45,7 +46,13 @@ def test_client_creation_ssl_disabled(self, mock_minio_class): mock_minio = Mock() mock_minio_class.return_value = mock_minio - client = ObjectStoreClient(self.creds, disable_ssl=True) + S3Client(S3Config( + access_key_id="test_key", + secret_access_key="test_secret", + bucket="test-bucket", + host="s3.amazonaws.com", + disable_ssl=True, + )) mock_minio_class.assert_called_once_with( endpoint="s3.amazonaws.com", @@ -59,14 +66,14 @@ def test_client_creation_failure(self, mock_minio_class): mock_minio_class.side_effect = Exception("Connection failed") with pytest.raises(ClientCreationError, match="Failed to create MinIO client"): - ObjectStoreClient(self.creds) + S3Client(self.config) @patch('sap_cloud_sdk.objectstore._s3.Minio') def test_put_object_from_bytes_success(self, mock_minio_class): mock_minio = Mock() mock_minio_class.return_value = mock_minio - client = ObjectStoreClient(self.creds) + client = S3Client(self.config) test_data = b"Hello, World!" client.put_object_from_bytes("test.txt", test_data, "text/plain") @@ -82,7 +89,7 @@ def test_put_object_from_bytes_success(self, mock_minio_class): @patch('sap_cloud_sdk.objectstore._s3.Minio') def test_put_object_from_bytes_validation(self, mock_minio_class): mock_minio_class.return_value = Mock() - client = ObjectStoreClient(self.creds) + client = S3Client(self.config) with pytest.raises(ValueError, match="name must be a non-empty string"): client.put_object_from_bytes("", b"data", "text/plain") @@ -100,7 +107,7 @@ def test_put_object_from_bytes_s3_error(self, mock_minio_class): mock_minio.put_object.side_effect = s3_error mock_minio_class.return_value = mock_minio - client = ObjectStoreClient(self.creds) + client = S3Client(self.config) with pytest.raises(ObjectOperationError, match="Failed to upload object"): client.put_object_from_bytes("test.txt", b"data", "text/plain") @@ -110,7 +117,7 @@ def test_put_object_from_stream_success(self, mock_minio_class): mock_minio = Mock() mock_minio_class.return_value = mock_minio - client = ObjectStoreClient(self.creds) + client = S3Client(self.config) stream = io.BytesIO(b"stream data") client.put_object("test.txt", stream, 11, "text/plain") @@ -126,7 +133,7 @@ def test_put_object_from_stream_success(self, mock_minio_class): @patch('sap_cloud_sdk.objectstore._s3.Minio') def test_put_object_validation(self, mock_minio_class): mock_minio_class.return_value = Mock() - client = ObjectStoreClient(self.creds) + client = S3Client(self.config) with pytest.raises(ValueError, match="size must be non-negative"): client.put_object("test.txt", io.BytesIO(b"data"), -1, "text/plain") @@ -139,7 +146,7 @@ def test_put_object_from_file_success(self, mock_getsize, mock_isfile, mock_file mock_minio = Mock() mock_minio_class.return_value = mock_minio - client = ObjectStoreClient(self.creds) + client = S3Client(self.config) client.put_object_from_file("test.txt", "/path/to/file.txt", "text/plain") mock_isfile.assert_called_once_with("/path/to/file.txt") @@ -151,7 +158,7 @@ def test_put_object_from_file_success(self, mock_getsize, mock_isfile, mock_file @patch('os.path.isfile', return_value=False) def test_put_object_from_file_not_found(self, mock_isfile, mock_file, mock_minio_class): mock_minio_class.return_value = Mock() - client = ObjectStoreClient(self.creds) + client = S3Client(self.config) with pytest.raises(ObjectOperationError, match="File not found"): client.put_object_from_file("test.txt", "/nonexistent.txt", "text/plain") @@ -163,7 +170,7 @@ def test_get_object_success(self, mock_minio_class): mock_minio.get_object.return_value = mock_response mock_minio_class.return_value = mock_minio - client = ObjectStoreClient(self.creds) + client = S3Client(self.config) result = client.get_object("test.txt") mock_minio.get_object.assert_called_once_with( @@ -179,7 +186,7 @@ def test_get_object_not_found(self, mock_minio_class): mock_minio.get_object.side_effect = s3_error mock_minio_class.return_value = mock_minio - client = ObjectStoreClient(self.creds) + client = S3Client(self.config) with pytest.raises(ObjectNotFoundError, match="Object 'test.txt' not found"): client.get_object("test.txt") @@ -189,7 +196,7 @@ def test_delete_object_success(self, mock_minio_class): mock_minio = Mock() mock_minio_class.return_value = mock_minio - client = ObjectStoreClient(self.creds) + client = S3Client(self.config) client.delete_object("test.txt") mock_minio.remove_object.assert_called_once_with( @@ -204,7 +211,7 @@ def test_delete_object_not_found_ignored(self, mock_minio_class): mock_minio.remove_object.side_effect = s3_error mock_minio_class.return_value = mock_minio - client = ObjectStoreClient(self.creds) + client = S3Client(self.config) client.delete_object("test.txt") @patch('sap_cloud_sdk.objectstore._s3.Minio') @@ -222,7 +229,7 @@ def test_list_objects_success(self, mock_minio_class): mock_minio.list_objects.return_value = [mock_obj1] mock_minio_class.return_value = mock_minio - client = ObjectStoreClient(self.creds) + client = S3Client(self.config) result = client.list_objects("prefix/") mock_minio.list_objects.assert_called_once_with( @@ -241,7 +248,7 @@ def test_list_objects_s3_error(self, mock_minio_class): mock_minio.list_objects.side_effect = s3_error mock_minio_class.return_value = mock_minio - client = ObjectStoreClient(self.creds) + client = S3Client(self.config) with pytest.raises(ListObjectsError, match="Failed to list objects"): client.list_objects("prefix/") @@ -258,7 +265,7 @@ def test_head_object_success(self, mock_minio_class): mock_minio.stat_object.return_value = mock_stat mock_minio_class.return_value = mock_minio - client = ObjectStoreClient(self.creds) + client = S3Client(self.config) result = client.head_object("test.txt") mock_minio.stat_object.assert_called_once_with( @@ -277,7 +284,7 @@ def test_head_object_not_found(self, mock_minio_class): mock_minio.stat_object.side_effect = s3_error mock_minio_class.return_value = mock_minio - client = ObjectStoreClient(self.creds) + client = S3Client(self.config) with pytest.raises(ObjectNotFoundError, match="Object 'test.txt' not found"): client.head_object("test.txt") @@ -288,7 +295,7 @@ def test_object_exists_true(self, mock_minio_class): mock_minio.stat_object.return_value = Mock() mock_minio_class.return_value = mock_minio - client = ObjectStoreClient(self.creds) + client = S3Client(self.config) result = client.object_exists("test.txt") assert result is True @@ -300,7 +307,7 @@ def test_object_exists_false(self, mock_minio_class): mock_minio.stat_object.side_effect = s3_error mock_minio_class.return_value = mock_minio - client = ObjectStoreClient(self.creds) + client = S3Client(self.config) result = client.object_exists("test.txt") assert result is False @@ -308,7 +315,7 @@ def test_object_exists_false(self, mock_minio_class): @patch('sap_cloud_sdk.objectstore._s3.Minio') def test_get_object_empty_name_validation(self, mock_minio_class): mock_minio_class.return_value = Mock() - client = ObjectStoreClient(self.creds) + client = S3Client(self.config) with pytest.raises(ValueError, match="name must be a non-empty string"): client.get_object("") @@ -316,7 +323,7 @@ def test_get_object_empty_name_validation(self, mock_minio_class): @patch('sap_cloud_sdk.objectstore._s3.Minio') def test_delete_object_empty_name_validation(self, mock_minio_class): mock_minio_class.return_value = Mock() - client = ObjectStoreClient(self.creds) + client = S3Client(self.config) with pytest.raises(ValueError, match="name must be a non-empty string"): client.delete_object("") @@ -324,7 +331,7 @@ def test_delete_object_empty_name_validation(self, mock_minio_class): @patch('sap_cloud_sdk.objectstore._s3.Minio') def test_head_object_empty_name_validation(self, mock_minio_class): mock_minio_class.return_value = Mock() - client = ObjectStoreClient(self.creds) + client = S3Client(self.config) with pytest.raises(ValueError, match="name must be a non-empty string"): client.head_object("") @@ -332,7 +339,7 @@ def test_head_object_empty_name_validation(self, mock_minio_class): @patch('sap_cloud_sdk.objectstore._s3.Minio') def test_object_exists_empty_name_validation(self, mock_minio_class): mock_minio_class.return_value = Mock() - client = ObjectStoreClient(self.creds) + client = S3Client(self.config) with pytest.raises(ValueError, match="name must be a non-empty string"): client.object_exists("") @@ -340,7 +347,7 @@ def test_object_exists_empty_name_validation(self, mock_minio_class): @patch('sap_cloud_sdk.objectstore._s3.Minio') def test_list_objects_prefix_validation(self, mock_minio_class): mock_minio_class.return_value = Mock() - client = ObjectStoreClient(self.creds) + client = S3Client(self.config) with pytest.raises(ValueError, match="prefix must be a string"): client.list_objects(123) # ty: ignore[invalid-argument-type] diff --git a/uv.lock b/uv.lock index 2c98946e..b675605e 100644 --- a/uv.lock +++ b/uv.lock @@ -161,9 +161,9 @@ name = "aiologic" version = "0.16.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "sniffio", marker = "python_full_version < '3.13'" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, - { name = "wrapt", marker = "python_full_version < '3.13'" }, + { name = "sniffio" }, + { name = "typing-extensions" }, + { name = "wrapt" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a8/13/50b91a3ea6b030d280d2654be97c48b6ed81753a50286ee43c646ba36d3c/aiologic-0.16.0.tar.gz", hash = "sha256:c267ccbd3ff417ec93e78d28d4d577ccca115d5797cdbd16785a551d9658858f", size = 225952, upload-time = "2025-11-27T23:48:41.195Z" } wheels = [ @@ -275,6 +275,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, ] +[[package]] +name = "azure-core" +version = "1.41.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/f3/b416179e408990df5db0d516283022dde0f5d0111d98c1a848e41853e81c/azure_core-1.41.0.tar.gz", hash = "sha256:f46ff5dfcd230f25cf1c19e8a34b8dc08a337b2503e268bb600a16c00db8ad5a", size = 381042, upload-time = "2026-05-07T23:30:54.302Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/db/325c6d7312d2200251c52323878281045aaffcb5586612296484e4280eaa/azure_core-1.41.0-py3-none-any.whl", hash = "sha256:522b4011e8180b1a3dcd2024396a4e7fe9ac37fb8597db47163d230b5efe892d", size = 220920, upload-time = "2026-05-07T23:30:56.357Z" }, +] + +[[package]] +name = "azure-storage-blob" +version = "12.30.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-core" }, + { name = "cryptography" }, + { name = "isodate" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3b/48/84a820d898267f662b5c06f7cd76fdb8a9e272b44aa9376cef3ec0f6a294/azure_storage_blob-12.30.0.tar.gz", hash = "sha256:2cd74d4d5731e5eb6b8d5c5056ee115a5e88f8fdf22517b739836fda685018be", size = 618229, upload-time = "2026-06-08T11:45:35.575Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/0b/e106f0fd7fa785867d9ffcc47dc9e6237c0e58f51058473b777487a98edc/azure_storage_blob-12.30.0-py3-none-any.whl", hash = "sha256:d415ac50b67a8da6b3ae7e9f1014b1b55cd7aafa0b8d4ca9b380568dc7360423", size = 435610, upload-time = "2026-06-08T11:45:37.213Z" }, +] + [[package]] name = "blinker" version = "1.9.0" @@ -615,8 +643,8 @@ name = "culsans" version = "0.11.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "aiologic", marker = "python_full_version < '3.13'" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "aiologic" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d9/e3/49afa1bc180e0d28008ec6bcdf82a4072d1c7a41032b5b759b60814ca4b0/culsans-0.11.0.tar.gz", hash = "sha256:0b43d0d05dce6106293d114c86e3fb4bfc63088cfe8ff08ed3fe36891447fe33", size = 107546, upload-time = "2025-12-31T23:15:38.196Z" } wheels = [ @@ -665,9 +693,9 @@ resolution-markers = [ "python_full_version < '3.12'", ] dependencies = [ - { name = "asgiref", marker = "python_full_version < '3.12'" }, - { name = "sqlparse", marker = "python_full_version < '3.12'" }, - { name = "tzdata", marker = "python_full_version < '3.12' and sys_platform == 'win32'" }, + { name = "asgiref" }, + { name = "sqlparse" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a9/26/889449d521ae508b26de715954faecd8bcf3f740affb81b2d146a83b42a5/django-5.2.16.tar.gz", hash = "sha256:59ea02020c3136fce14bef0bbece21a10a4febef5eed1c51c22ae468efa22200", size = 10890894, upload-time = "2026-07-07T13:52:17.005Z" } wheels = [ @@ -685,9 +713,9 @@ resolution-markers = [ "python_full_version == '3.12.*'", ] dependencies = [ - { name = "asgiref", marker = "python_full_version >= '3.12'" }, - { name = "sqlparse", marker = "python_full_version >= '3.12'" }, - { name = "tzdata", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" }, + { name = "asgiref" }, + { name = "sqlparse" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/89/55/664f24ff81c9ea19cb7dfc851afeae1f3c2390c7aee01d4ded68b5c1580d/django-6.0.7.tar.gz", hash = "sha256:2998503fc083124fb58037084bfa00de323c7c743f05f1b4284e77bff0ab8890", size = 10921299, upload-time = "2026-07-07T13:51:26.485Z" } wheels = [ @@ -940,6 +968,66 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ee/fc/2cdc74252746f547f81ff3f02d4d4234a3f411b5de5b61af97e633a060b9/google_auth-2.52.0-py3-none-any.whl", hash = "sha256:aee92803ba0ff93a70a3b8a35c7b4797837751cd6380b63ff38372b98f3ed627", size = 245614, upload-time = "2026-05-07T19:45:21.914Z" }, ] +[[package]] +name = "google-cloud-core" +version = "2.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-api-core" }, + { name = "google-auth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4a/c6/9d7d9ed6703eb35306ca7bf381fb66ba8d978c61a1b550a2e1730a4c4ce8/google_cloud_core-2.6.1.tar.gz", hash = "sha256:1e044b131f2ae097b92312fa195164b0aeb6dc6a88e00231e1210516314c420c", size = 36017, upload-time = "2026-08-06T06:24:18.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/4f/37960d5255988b218c40c9b5a2b731013684294a50735d1dd1ab4894e531/google_cloud_core-2.6.1-py3-none-any.whl", hash = "sha256:2682a8a4474a32f56292fb4bca7fa7e4fb0b4af958f6abfe4bca8d195747fd45", size = 29393, upload-time = "2026-08-06T06:23:11.405Z" }, +] + +[[package]] +name = "google-cloud-storage" +version = "3.13.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-api-core" }, + { name = "google-auth" }, + { name = "google-cloud-core" }, + { name = "google-crc32c" }, + { name = "google-resumable-media" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/7e/73bb7512df1d1aad6ce3f9aed847cd40e0cd400ba4a85d86ab8eb412e9cc/google_cloud_storage-3.13.1.tar.gz", hash = "sha256:a80bf8cac2794808aa61c50c5f769ecbbe2d10331bacd0d69d30e59b14b346b2", size = 17341051, upload-time = "2026-08-06T06:24:42.229Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/6f/d69f0e185e08ddb58c323a0a935af2b492907b5de362bc08933b0a3b5644/google_cloud_storage-3.13.1-py3-none-any.whl", hash = "sha256:98208de6c21e85cecd3eb44551894efff33d98365500e178867d4305854a770a", size = 341486, upload-time = "2026-08-06T06:23:36.548Z" }, +] + +[[package]] +name = "google-crc32c" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/03/41/4b9c02f99e4c5fb477122cd5437403b552873f014616ac1d19ac8221a58d/google_crc32c-1.8.0.tar.gz", hash = "sha256:a428e25fb7691024de47fecfbff7ff957214da51eddded0da0ae0e0f03a2cf79", size = 14192, upload-time = "2025-12-16T00:35:25.142Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/ef/21ccfaab3d5078d41efe8612e0ed0bfc9ce22475de074162a91a25f7980d/google_crc32c-1.8.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:014a7e68d623e9a4222d663931febc3033c5c7c9730785727de2a81f87d5bab8", size = 31298, upload-time = "2025-12-16T00:20:32.241Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b8/f8413d3f4b676136e965e764ceedec904fe38ae8de0cdc52a12d8eb1096e/google_crc32c-1.8.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:86cfc00fe45a0ac7359e5214a1704e51a99e757d0272554874f419f79838c5f7", size = 30872, upload-time = "2025-12-16T00:33:58.785Z" }, + { url = "https://files.pythonhosted.org/packages/f6/fd/33aa4ec62b290477181c55bb1c9302c9698c58c0ce9a6ab4874abc8b0d60/google_crc32c-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:19b40d637a54cb71e0829179f6cb41835f0fbd9e8eb60552152a8b52c36cbe15", size = 33243, upload-time = "2025-12-16T00:40:21.46Z" }, + { url = "https://files.pythonhosted.org/packages/71/03/4820b3bd99c9653d1a5210cb32f9ba4da9681619b4d35b6a052432df4773/google_crc32c-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:17446feb05abddc187e5441a45971b8394ea4c1b6efd88ab0af393fd9e0a156a", size = 33608, upload-time = "2025-12-16T00:40:22.204Z" }, + { url = "https://files.pythonhosted.org/packages/7c/43/acf61476a11437bf9733fb2f70599b1ced11ec7ed9ea760fdd9a77d0c619/google_crc32c-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:71734788a88f551fbd6a97be9668a0020698e07b2bf5b3aa26a36c10cdfb27b2", size = 34439, upload-time = "2025-12-16T00:35:20.458Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5f/7307325b1198b59324c0fa9807cafb551afb65e831699f2ce211ad5c8240/google_crc32c-1.8.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:4b8286b659c1335172e39563ab0a768b8015e88e08329fa5321f774275fc3113", size = 31300, upload-time = "2025-12-16T00:21:56.723Z" }, + { url = "https://files.pythonhosted.org/packages/21/8e/58c0d5d86e2220e6a37befe7e6a94dd2f6006044b1a33edf1ff6d9f7e319/google_crc32c-1.8.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:2a3dc3318507de089c5384cc74d54318401410f82aa65b2d9cdde9d297aca7cb", size = 30867, upload-time = "2025-12-16T00:38:31.302Z" }, + { url = "https://files.pythonhosted.org/packages/ce/a9/a780cc66f86335a6019f557a8aaca8fbb970728f0efd2430d15ff1beae0e/google_crc32c-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14f87e04d613dfa218d6135e81b78272c3b904e2a7053b841481b38a7d901411", size = 33364, upload-time = "2025-12-16T00:40:22.96Z" }, + { url = "https://files.pythonhosted.org/packages/21/3f/3457ea803db0198c9aaca2dd373750972ce28a26f00544b6b85088811939/google_crc32c-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cb5c869c2923d56cb0c8e6bcdd73c009c36ae39b652dbe46a05eb4ef0ad01454", size = 33740, upload-time = "2025-12-16T00:40:23.96Z" }, + { url = "https://files.pythonhosted.org/packages/df/c0/87c2073e0c72515bb8733d4eef7b21548e8d189f094b5dad20b0ecaf64f6/google_crc32c-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:3cc0c8912038065eafa603b238abf252e204accab2a704c63b9e14837a854962", size = 34437, upload-time = "2025-12-16T00:35:21.395Z" }, + { url = "https://files.pythonhosted.org/packages/d1/db/000f15b41724589b0e7bc24bc7a8967898d8d3bc8caf64c513d91ef1f6c0/google_crc32c-1.8.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:3ebb04528e83b2634857f43f9bb8ef5b2bbe7f10f140daeb01b58f972d04736b", size = 31297, upload-time = "2025-12-16T00:23:20.709Z" }, + { url = "https://files.pythonhosted.org/packages/d7/0d/8ebed0c39c53a7e838e2a486da8abb0e52de135f1b376ae2f0b160eb4c1a/google_crc32c-1.8.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:450dc98429d3e33ed2926fc99ee81001928d63460f8538f21a5d6060912a8e27", size = 30867, upload-time = "2025-12-16T00:43:14.628Z" }, + { url = "https://files.pythonhosted.org/packages/ce/42/b468aec74a0354b34c8cbf748db20d6e350a68a2b0912e128cabee49806c/google_crc32c-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3b9776774b24ba76831609ffbabce8cdf6fa2bd5e9df37b594221c7e333a81fa", size = 33344, upload-time = "2025-12-16T00:40:24.742Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e8/b33784d6fc77fb5062a8a7854e43e1e618b87d5ddf610a88025e4de6226e/google_crc32c-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:89c17d53d75562edfff86679244830599ee0a48efc216200691de8b02ab6b2b8", size = 33694, upload-time = "2025-12-16T00:40:25.505Z" }, + { url = "https://files.pythonhosted.org/packages/92/b1/d3cbd4d988afb3d8e4db94ca953df429ed6db7282ed0e700d25e6c7bfc8d/google_crc32c-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:57a50a9035b75643996fbf224d6661e386c7162d1dfdab9bc4ca790947d1007f", size = 34435, upload-time = "2025-12-16T00:35:22.107Z" }, + { url = "https://files.pythonhosted.org/packages/21/88/8ecf3c2b864a490b9e7010c84fd203ec8cf3b280651106a3a74dd1b0ca72/google_crc32c-1.8.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:e6584b12cb06796d285d09e33f63309a09368b9d806a551d8036a4207ea43697", size = 31301, upload-time = "2025-12-16T00:24:48.527Z" }, + { url = "https://files.pythonhosted.org/packages/36/c6/f7ff6c11f5ca215d9f43d3629163727a272eabc356e5c9b2853df2bfe965/google_crc32c-1.8.0-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:f4b51844ef67d6cf2e9425983274da75f18b1597bb2c998e1c0a0e8d46f8f651", size = 30868, upload-time = "2025-12-16T00:48:12.163Z" }, + { url = "https://files.pythonhosted.org/packages/56/15/c25671c7aad70f8179d858c55a6ae8404902abe0cdcf32a29d581792b491/google_crc32c-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b0d1a7afc6e8e4635564ba8aa5c0548e3173e41b6384d7711a9123165f582de2", size = 33381, upload-time = "2025-12-16T00:40:26.268Z" }, + { url = "https://files.pythonhosted.org/packages/42/fa/f50f51260d7b0ef5d4898af122d8a7ec5a84e2984f676f746445f783705f/google_crc32c-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8b3f68782f3cbd1bce027e48768293072813469af6a61a86f6bb4977a4380f21", size = 33734, upload-time = "2025-12-16T00:40:27.028Z" }, + { url = "https://files.pythonhosted.org/packages/08/a5/7b059810934a09fb3ccb657e0843813c1fee1183d3bc2c8041800374aa2c/google_crc32c-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:d511b3153e7011a27ab6ee6bb3a5404a55b994dc1a7322c0b87b29606d9790e2", size = 34878, upload-time = "2025-12-16T00:35:23.142Z" }, + { url = "https://files.pythonhosted.org/packages/52/c5/c171e4d8c44fec1422d801a6d2e5d7ddabd733eeda505c79730ee9607f07/google_crc32c-1.8.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:87fa445064e7db928226b2e6f0d5304ab4cd0339e664a4e9a25029f384d9bb93", size = 28615, upload-time = "2025-12-16T00:40:29.298Z" }, + { url = "https://files.pythonhosted.org/packages/9c/97/7d75fe37a7a6ed171a2cf17117177e7aab7e6e0d115858741b41e9dd4254/google_crc32c-1.8.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f639065ea2042d5c034bf258a9f085eaa7af0cd250667c0635a3118e8f92c69c", size = 28800, upload-time = "2025-12-16T00:40:30.322Z" }, +] + [[package]] name = "google-re2" version = "1.1.20251105" @@ -992,6 +1080,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6f/d1/4adcfcb9c95e3d064c9f7aaf6cb3a4fc842d86115014b9d4094db4d465b5/google_re2-1.1.20251105-1-cp314-cp314-win_arm64.whl", hash = "sha256:1d27f3a2a947ec1f721d0f14f661108acfd4f4d34f357ce28db951cc036656e5", size = 643093, upload-time = "2025-11-05T14:58:05.761Z" }, ] +[[package]] +name = "google-resumable-media" +version = "2.10.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-crc32c" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/f5/f35505e6091614e285056a495488cb0a9c1a9dcc88a4a3c91bbc5fd4835b/google_resumable_media-2.10.1.tar.gz", hash = "sha256:224975032ddb73f7ed9e2f0f4cc08ed1b06874c52d48cc8533e3eb72980b21a0", size = 2164548, upload-time = "2026-08-06T06:24:50.489Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/ba/77ef49baf338c03a11deadac984e3161b3f2b4fa4bb5aab160e7ca0fd522/google_resumable_media-2.10.1-py3-none-any.whl", hash = "sha256:4e2cbc704207ddc09f23b1f18e8ef4a4ccbfe0f1768b370e5c969704adbd0a1c", size = 81533, upload-time = "2026-08-06T06:23:45.464Z" }, +] + [[package]] name = "googleapis-common-protos" version = "1.75.0" @@ -1270,6 +1370,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, ] +[[package]] +name = "isodate" +version = "0.7.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/4d/e940025e2ce31a8ce1202635910747e5a87cc3a6a6bb2d00973375014749/isodate-0.7.2.tar.gz", hash = "sha256:4cd1aa0f43ca76f4a6c6c0292a85f40b35ec2e43e315b59f06e6d32171a953e6", size = 29705, upload-time = "2024-10-08T23:04:11.5Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/aa/0aca39a37d3c7eb941ba736ede56d689e7be91cab5d9ca846bde3999eba6/isodate-0.7.2-py3-none-any.whl", hash = "sha256:28009937d8031054830160fce6d409ed342816b543597cece116d966c6d99e15", size = 22320, upload-time = "2024-10-08T23:04:09.501Z" }, +] + [[package]] name = "itsdangerous" version = "2.2.0" @@ -3925,10 +4034,12 @@ wheels = [ [[package]] name = "sap-cloud-sdk" -version = "0.45.0" +version = "0.45.1" source = { editable = "." } dependencies = [ + { name = "azure-storage-blob" }, { name = "cryptography" }, + { name = "google-cloud-storage" }, { name = "grpcio" }, { name = "hatchling" }, { name = "httpx" }, @@ -3991,11 +4102,13 @@ dev = [ { name = "a2a-sdk" }, { name = "aiohttp" }, { name = "anyio" }, + { name = "azure-storage-blob" }, { name = "cryptography" }, { name = "django", version = "5.2.16", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "django", version = "6.0.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "fastapi" }, { name = "flask" }, + { name = "google-cloud-storage" }, { name = "httpx" }, { name = "langchain-community" }, { name = "langchain-core" }, @@ -4017,10 +4130,12 @@ dev = [ requires-dist = [ { name = "a2a-sdk", marker = "extra == 'extensibility'", specifier = ">=0.2.0" }, { name = "aiohttp", marker = "extra == 'aiohttp'", specifier = ">=3.9.0" }, + { name = "azure-storage-blob", specifier = ">=12.20.0" }, { name = "cryptography", specifier = ">=46.0.3" }, { name = "django", marker = "extra == 'django'", specifier = ">=4.0" }, { name = "fastapi", marker = "extra == 'fastapi'", specifier = ">=0.100.0" }, { name = "flask", marker = "extra == 'flask'", specifier = ">=3.0" }, + { name = "google-cloud-storage", specifier = ">=2.18.0" }, { name = "grpcio", specifier = ">=1.60.0" }, { name = "hatchling", specifier = "~=1.27.0" }, { name = "httpx", specifier = ">=0.27.0" }, @@ -4060,10 +4175,12 @@ dev = [ { name = "a2a-sdk", specifier = ">=0.2.0" }, { name = "aiohttp", specifier = ">=3.9.0" }, { name = "anyio", specifier = ">=3.6.2" }, + { name = "azure-storage-blob", specifier = ">=12.20.0" }, { name = "cryptography", specifier = ">=46.0.3" }, { name = "django", specifier = ">=4.0" }, { name = "fastapi", specifier = ">=0.100.0" }, { name = "flask", specifier = ">=3.0" }, + { name = "google-cloud-storage", specifier = ">=2.18.0" }, { name = "httpx", specifier = ">=0.27.0" }, { name = "langchain-community", specifier = ">=0.3.0" }, { name = "langchain-core", specifier = ">=1.2.7" },