From c951d3c813624928623121913d7faeb907d5a731 Mon Sep 17 00:00:00 2001 From: rootflo-hardik Date: Tue, 24 Mar 2026 10:27:02 +0530 Subject: [PATCH 1/3] feat(flo-cloud): add Azure Blob Storage provider - Adds AzureBlobStorage as a third cloud storage backend alongside AWS S3 and GCP GCS. - Implements the full CloudStorageHandler interface (get, upload, delete, list, presigned SAS URLs, text writer). - Supports both Service Principal (AZURE_CLIENT_ID, AZURE_CLIENT_SECRET, AZURE_TENANT_ID) and DefaultAzureCredential authentication. - Requires AZURE_STORAGE_ACCOUNT_URL to point to the storage account endpoint. - Also adds azure-storage-blob and azure-identity dependencies. --- .../flo_cloud/flo_cloud/azure/__init__.py | 3 + .../flo_cloud/flo_cloud/azure/blob_storage.py | 353 ++++++++++++++++++ .../flo_cloud/flo_cloud/cloud_storage.py | 20 +- .../server/packages/flo_cloud/pyproject.toml | 2 + wavefront/server/uv.lock | 32 ++ 5 files changed, 407 insertions(+), 3 deletions(-) create mode 100644 wavefront/server/packages/flo_cloud/flo_cloud/azure/blob_storage.py diff --git a/wavefront/server/packages/flo_cloud/flo_cloud/azure/__init__.py b/wavefront/server/packages/flo_cloud/flo_cloud/azure/__init__.py index e69de29b..abcd0774 100644 --- a/wavefront/server/packages/flo_cloud/flo_cloud/azure/__init__.py +++ b/wavefront/server/packages/flo_cloud/flo_cloud/azure/__init__.py @@ -0,0 +1,3 @@ +from .blob_storage import AzureBlobStorage + +__all__ = ['AzureBlobStorage'] diff --git a/wavefront/server/packages/flo_cloud/flo_cloud/azure/blob_storage.py b/wavefront/server/packages/flo_cloud/flo_cloud/azure/blob_storage.py new file mode 100644 index 00000000..fa7d4799 --- /dev/null +++ b/wavefront/server/packages/flo_cloud/flo_cloud/azure/blob_storage.py @@ -0,0 +1,353 @@ +import io +import os +import re +from contextlib import contextmanager +from datetime import datetime, timedelta, timezone +from itertools import islice +from typing import IO, ContextManager, List, Optional, Tuple + +from azure.core.exceptions import ResourceNotFoundError +from azure.identity import ClientSecretCredential, DefaultAzureCredential +from azure.storage.blob import ( + BlobSasPermissions, + BlobServiceClient, + generate_blob_sas, +) + +from .._types import CloudStorageHandler +from ..exceptions import CloudStorageFileNotFoundError + + +class AzureBlobStorage(CloudStorageHandler): + """Azure Blob Storage implementation""" + + def __init__( + self, + account_url: Optional[str] = None, + client_id: Optional[str] = None, + client_secret: Optional[str] = None, + tenant_id: Optional[str] = None, + ): + """ + Initialize Azure Blob Storage client. + + Two authentication modes are supported: + + 1. Service Principal (explicit credentials) — closest equivalent to a + GCP service account. Provide client_id, client_secret, and tenant_id. + These map to an Azure App Registration with the + "Storage Blob Data Contributor" role assigned on the storage account. + + 2. DefaultAzureCredential (no credential args) — tries a chain of + authentication methods in order: environment variables + (AZURE_CLIENT_ID / AZURE_CLIENT_SECRET / AZURE_TENANT_ID), Workload + Identity, Managed Identity, Azure CLI login, etc. + + Args: + account_url: Azure storage account URL, e.g. + "https://.blob.core.windows.net". + Falls back to the AZURE_STORAGE_ACCOUNT_URL env var. + client_id: Azure AD application (client) ID. + client_secret: Azure AD application client secret. + tenant_id: Azure AD tenant (directory) ID. + """ + resolved_url = account_url or os.environ.get('AZURE_STORAGE_ACCOUNT_URL') + if not resolved_url: + raise ValueError( + 'account_url must be provided or AZURE_STORAGE_ACCOUNT_URL must be set' + ) + self._account_url = resolved_url + self._account_name = self._parse_account_name(resolved_url) + + if client_id and client_secret and tenant_id: + credential = ClientSecretCredential( + tenant_id=tenant_id, + client_id=client_id, + client_secret=client_secret, + ) + else: + credential = DefaultAzureCredential() + + self._credential = credential + self.client = BlobServiceClient(account_url=resolved_url, credential=credential) + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + @staticmethod + def _parse_account_name(account_url: str) -> str: + """Extract the storage account name from the account URL.""" + match = re.match(r'https://([^.]+)\.blob\.core\.windows\.net', account_url) + if not match: + raise ValueError( + f'Cannot parse account name from account_url: {account_url!r}' + ) + return match.group(1) + + # ------------------------------------------------------------------ + # CloudStorageHandler interface + # ------------------------------------------------------------------ + + def get_file(self, bucket_name: str, file_path: str) -> bytes: + """ + Download a blob from an Azure container. + + Args: + bucket_name: Name of the Azure container (equivalent to S3 bucket). + file_path: Path / name of the blob inside the container. + + Returns: + File content as bytes. + + Raises: + CloudStorageFileNotFoundError: If the blob or container does not exist. + Exception: For other errors. + """ + try: + blob_client = self.client.get_blob_client( + container=bucket_name, blob=file_path + ) + return blob_client.download_blob().readall() + except ResourceNotFoundError: + raise CloudStorageFileNotFoundError(bucket_name, file_path) + except Exception as e: + raise Exception(f'Error reading file from Azure Blob Storage: {str(e)}') + + def save_large_file( + self, + data: bytes, + bucket_name: str, + key: str, + content_type: Optional[str] = None, + ) -> None: + """ + Upload a large file to Azure Blob Storage using chunked upload. + + Azure's SDK handles chunked (block blob) upload automatically via + upload_blob when a BytesIO stream is provided. + + Args: + data: File data in bytes. + bucket_name: Name of the Azure container. + key: Blob name / path inside the container. + content_type: MIME type of the file (optional). + """ + try: + blob_client = self.client.get_blob_client(container=bucket_name, blob=key) + kwargs = {'overwrite': True} + if content_type is not None: + kwargs['content_settings'] = _content_settings(content_type) + + blob_client.upload_blob(io.BytesIO(data), **kwargs) + except Exception as e: + raise Exception( + f'Error uploading large file to Azure Blob Storage: {str(e)}' + ) + + def save_small_file( + self, + file_content: bytes, + bucket_name: str, + key: str, + content_type: Optional[str] = None, + ) -> None: + """ + Upload a small file to Azure Blob Storage in a single operation. + + Args: + file_content: File content in bytes. + bucket_name: Name of the Azure container. + key: Blob name / path inside the container. + content_type: MIME type of the file (optional). + """ + try: + blob_client = self.client.get_blob_client(container=bucket_name, blob=key) + kwargs = {'overwrite': True} + if content_type is not None: + kwargs['content_settings'] = _content_settings(content_type) + + blob_client.upload_blob(file_content, **kwargs) + except Exception as e: + raise Exception( + f'Error uploading small file to Azure Blob Storage: {str(e)}' + ) + + def delete_file(self, bucket_name: str, file_path: str) -> None: + """ + Delete a blob from an Azure container. + + Args: + bucket_name: Name of the Azure container. + file_path: Blob name / path inside the container. + """ + try: + blob_client = self.client.get_blob_client( + container=bucket_name, blob=file_path + ) + blob_client.delete_blob() + except ResourceNotFoundError: + raise CloudStorageFileNotFoundError(bucket_name, file_path) + except Exception as e: + raise Exception(f'Error deleting file from Azure Blob Storage: {str(e)}') + + def get_bucket_key(self, value: str): + """ + Parse an azure:// URL into (container_name, blob_key). + + Args: + value: URL in the format "azure://container-name/blob-path". + + Returns: + Tuple of (container_name, blob_key). + """ + match = re.match(r'azure://([^/]+)/(.+)', value) + if not match: + raise ValueError( + f'Invalid Azure Blob Storage URL format: {value!r}. ' + 'Expected azure://container-name/blob-path' + ) + return match.group(1), match.group(2) + + def generate_presigned_url( + self, bucket_name: str, key: str, type: str, expiresIn: int = 300 + ) -> str: + """ + Generate a SAS (Shared Access Signature) URL for a blob. + + Azure's equivalent of S3 presigned URLs / GCS signed URLs are SAS tokens. + The returned URL is the full blob URL with the SAS token appended. + + Args: + bucket_name: Name of the Azure container. + key: Blob name / path inside the container. + type: HTTP method — 'GET', 'PUT', or 'POST' (case-insensitive). + expiresIn: Expiration time in seconds (default: 300). + + Returns: + Full SAS URL as a string. + + Raises: + ValueError: If type is not supported. + Exception: If SAS generation fails. + """ + try: + method = type.upper() + permissions = _sas_permissions(method) + + expiry = datetime.now(timezone.utc) + timedelta(seconds=expiresIn) + + # generate_blob_sas requires a user_delegation_key or account_key. + # We obtain a user delegation key using our credential, which works + # with both service principal and DefaultAzureCredential. + start = datetime.now(timezone.utc) - timedelta(seconds=60) + udk = self.client.get_user_delegation_key( + key_start_time=start, key_expiry_time=expiry + ) + + sas_token = generate_blob_sas( + account_name=self._account_name, + container_name=bucket_name, + blob_name=key, + user_delegation_key=udk, + permission=permissions, + expiry=expiry, + ) + + blob_url = f'{self._account_url}/{bucket_name}/{key}?{sas_token}' + return blob_url + except Exception as e: + raise Exception( + f'Error generating SAS URL for Azure Blob Storage: {str(e)}' + ) + + def list_files( + self, bucket_name: str, prefix: str, page_size: int = 50, page_number: int = 1 + ) -> Tuple[List[str], bool]: + """ + List blobs in an Azure container with prefix filtering and pagination. + + Args: + bucket_name: Name of the Azure container. + prefix: Prefix to filter blobs by name. + page_size: Number of blobs per page (default: 50). + page_number: 1-based page number (default: 1). + + Returns: + Tuple of (list of blob names, has_next_page). + + Raises: + Exception: If listing fails. + """ + try: + if page_number < 1: + raise ValueError('page_number must be >= 1') + if page_size < 1: + raise ValueError('page_size must be >= 1') + + container_client = self.client.get_container_client(bucket_name) + blobs_iterator = container_client.list_blobs(name_starts_with=prefix) + + start_index = (page_number - 1) * page_size + end_index = start_index + page_size + 1 + + page_slice = islice(blobs_iterator, start_index, end_index) + blob_names = [blob.name for blob in page_slice] + + has_next_page = len(blob_names) > page_size + if has_next_page: + return blob_names[:page_size], True + return blob_names, False + + except ResourceNotFoundError: + raise Exception(f'Container {bucket_name} not found') + except Exception as e: + raise Exception(f'Error listing files from Azure Blob Storage: {str(e)}') + + def open_text_writer( + self, bucket_name: str, key: str, content_type: Optional[str] = None + ) -> ContextManager[IO[str]]: + """ + Open a text-mode writer for Azure Blob Storage. + + Azure SDK does not provide a native streaming text writer, so this + follows the same pattern as the S3 implementation: buffer content in + memory via StringIO and upload on context exit. + """ + + @contextmanager + def _writer() -> IO[str]: + buffer = io.StringIO() + try: + yield buffer + data = buffer.getvalue().encode('utf-8') + self.save_large_file(data, bucket_name, key, content_type) + finally: + buffer.close() + + return _writer() + + +# ------------------------------------------------------------------ +# Private helpers +# ------------------------------------------------------------------ + + +def _content_settings(content_type: str): + """Build a ContentSettings object for the given MIME type.""" + from azure.storage.blob import ContentSettings + + return ContentSettings(content_type=content_type) + + +def _sas_permissions(method: str) -> BlobSasPermissions: + """Map an HTTP method string to the corresponding BlobSasPermissions.""" + if method == 'GET': + return BlobSasPermissions(read=True) + elif method == 'PUT': + return BlobSasPermissions(write=True, create=True) + elif method == 'POST': + return BlobSasPermissions(write=True, create=True) + raise ValueError( + f"Unsupported SAS permission type: {method!r}. Expected 'GET', 'PUT', or 'POST'." + ) diff --git a/wavefront/server/packages/flo_cloud/flo_cloud/cloud_storage.py b/wavefront/server/packages/flo_cloud/flo_cloud/cloud_storage.py index b5d80227..849a6135 100644 --- a/wavefront/server/packages/flo_cloud/flo_cloud/cloud_storage.py +++ b/wavefront/server/packages/flo_cloud/flo_cloud/cloud_storage.py @@ -1,6 +1,7 @@ from typing import Union, List, Tuple, Optional, IO, ContextManager from .aws.s3 import S3Storage from .gcp.gcs import GCSStorage +from .azure.blob_storage import AzureBlobStorage from ._types import CloudStorageHandler, CloudProvider @@ -31,6 +32,8 @@ def get_handler( return S3Storage() elif provider == CloudProvider.GCP: return GCSStorage() + elif provider == CloudProvider.AZURE: + return AzureBlobStorage(**credentials) else: raise ValueError(f'Unsupported cloud provider: {provider}') @@ -80,6 +83,13 @@ def _convert_to_valid_type(self, type: str) -> str: return 'PUT' elif type == 'post_object' or type == 'post': return 'POST' + elif self.provider == CloudProvider.AZURE: + if type == 'get' or type == 'get_object': + return 'GET' + elif type == 'put' or type == 'put_object': + return 'PUT' + elif type == 'post' or type == 'post_object': + return 'POST' raise ValueError(f"Unsupported type '{type}' for provider '{self.provider}'") def read_file(self, bucket_name: str, file_path: str) -> bytes: @@ -140,9 +150,13 @@ def save_small_file( self.handler.save_small_file(file_content, bucket_name, key, content_type) def file_protocol(self) -> str: - return ( - 's3' if self.provider == 'aws' else 'gs' if self.provider == 'gcp' else None - ) + if self.provider == CloudProvider.AWS: + return 's3' + elif self.provider == CloudProvider.GCP: + return 'gs' + elif self.provider == CloudProvider.AZURE: + return 'azure' + return None def get_bucket_key(self, value) -> str: return self.handler.get_bucket_key(value) diff --git a/wavefront/server/packages/flo_cloud/pyproject.toml b/wavefront/server/packages/flo_cloud/pyproject.toml index 5369e0ad..a6ba6c04 100644 --- a/wavefront/server/packages/flo_cloud/pyproject.toml +++ b/wavefront/server/packages/flo_cloud/pyproject.toml @@ -8,6 +8,8 @@ authors = [ readme = "README.md" requires-python = ">=3.11" dependencies = [ + "azure-identity>=1.17.0", + "azure-storage-blob>=12.20.0", "boto3<=1.38.40", "cryptography>=45.0.4", "google-cloud-bigquery==3.34.0", diff --git a/wavefront/server/uv.lock b/wavefront/server/uv.lock index d295378f..0654a46b 100644 --- a/wavefront/server/uv.lock +++ b/wavefront/server/uv.lock @@ -523,6 +523,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/75/54/81683b6756676a22e037b209695b08008258e603f7e47c56834029c5922a/azure_identity-1.25.0-py3-none-any.whl", hash = "sha256:becaec086bbdf8d1a6aa4fb080c2772a0f824a97d50c29637ec8cc4933f1e82d", size = 190861, upload-time = "2025-09-12T01:30:06.474Z" }, ] +[[package]] +name = "azure-storage-blob" +version = "12.28.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/71/24/072ba8e27b0e2d8fec401e9969b429d4f5fc4c8d4f0f05f4661e11f7234a/azure_storage_blob-12.28.0.tar.gz", hash = "sha256:e7d98ea108258d29aa0efbfd591b2e2075fa1722a2fae8699f0b3c9de11eff41", size = 604225, upload-time = "2026-01-06T23:48:57.282Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/3a/6ef2047a072e54e1142718d433d50e9514c999a58f51abfff7902f3a72f8/azure_storage_blob-12.28.0-py3-none-any.whl", hash = "sha256:00fb1db28bf6a7b7ecaa48e3b1d5c83bfadacc5a678b77826081304bd87d6461", size = 431499, upload-time = "2026-01-06T23:48:58.995Z" }, +] + [[package]] name = "bcrypt" version = "4.3.0" @@ -1388,6 +1403,8 @@ name = "flo-cloud" version = "0.1.0" source = { editable = "packages/flo_cloud" } dependencies = [ + { name = "azure-identity" }, + { name = "azure-storage-blob" }, { name = "boto3" }, { name = "cryptography" }, { name = "google-cloud-bigquery" }, @@ -1399,6 +1416,8 @@ dependencies = [ [package.metadata] requires-dist = [ + { name = "azure-identity", specifier = ">=1.17.0" }, + { name = "azure-storage-blob", specifier = ">=12.20.0" }, { name = "boto3", specifier = "<=1.38.40" }, { name = "cryptography", specifier = ">=45.0.4" }, { name = "google-cloud-bigquery", specifier = "==3.34.0" }, @@ -1951,6 +1970,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a4/de/f28ced0a67749cac23fecb02b694f6473f47686dff6afaa211d186e2ef9c/greenlet-3.2.4-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:96378df1de302bc38e99c3a9aa311967b7dc80ced1dcc6f171e99842987882a2", size = 272305, upload-time = "2025-08-07T13:15:41.288Z" }, { url = "https://files.pythonhosted.org/packages/09/16/2c3792cba130000bf2a31c5272999113f4764fd9d874fb257ff588ac779a/greenlet-3.2.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1ee8fae0519a337f2329cb78bd7a8e128ec0f881073d43f023c7b8d4831d5246", size = 632472, upload-time = "2025-08-07T13:42:55.044Z" }, { url = "https://files.pythonhosted.org/packages/ae/8f/95d48d7e3d433e6dae5b1682e4292242a53f22df82e6d3dda81b1701a960/greenlet-3.2.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:94abf90142c2a18151632371140b3dba4dee031633fe614cb592dbb6c9e17bc3", size = 644646, upload-time = "2025-08-07T13:45:26.523Z" }, + { url = "https://files.pythonhosted.org/packages/d5/5e/405965351aef8c76b8ef7ad370e5da58d57ef6068df197548b015464001a/greenlet-3.2.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:4d1378601b85e2e5171b99be8d2dc85f594c79967599328f95c1dc1a40f1c633", size = 640519, upload-time = "2025-08-07T13:53:13.928Z" }, { url = "https://files.pythonhosted.org/packages/25/5d/382753b52006ce0218297ec1b628e048c4e64b155379331f25a7316eb749/greenlet-3.2.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0db5594dce18db94f7d1650d7489909b57afde4c580806b8d9203b6e79cdc079", size = 639707, upload-time = "2025-08-07T13:18:27.146Z" }, { url = "https://files.pythonhosted.org/packages/1f/8e/abdd3f14d735b2929290a018ecf133c901be4874b858dd1c604b9319f064/greenlet-3.2.4-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2523e5246274f54fdadbce8494458a2ebdcdbc7b802318466ac5606d3cded1f8", size = 587684, upload-time = "2025-08-07T13:18:25.164Z" }, { url = "https://files.pythonhosted.org/packages/5d/65/deb2a69c3e5996439b0176f6651e0052542bb6c8f8ec2e3fba97c9768805/greenlet-3.2.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1987de92fec508535687fb807a5cea1560f6196285a4cde35c100b8cd632cc52", size = 1116647, upload-time = "2025-08-07T13:42:38.655Z" }, @@ -1961,6 +1981,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/44/69/9b804adb5fd0671f367781560eb5eb586c4d495277c93bde4307b9e28068/greenlet-3.2.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3b67ca49f54cede0186854a008109d6ee71f66bd57bb36abd6d0a0267b540cdd", size = 274079, upload-time = "2025-08-07T13:15:45.033Z" }, { url = "https://files.pythonhosted.org/packages/46/e9/d2a80c99f19a153eff70bc451ab78615583b8dac0754cfb942223d2c1a0d/greenlet-3.2.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddf9164e7a5b08e9d22511526865780a576f19ddd00d62f8a665949327fde8bb", size = 640997, upload-time = "2025-08-07T13:42:56.234Z" }, { url = "https://files.pythonhosted.org/packages/3b/16/035dcfcc48715ccd345f3a93183267167cdd162ad123cd93067d86f27ce4/greenlet-3.2.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f28588772bb5fb869a8eb331374ec06f24a83a9c25bfa1f38b6993afe9c1e968", size = 655185, upload-time = "2025-08-07T13:45:27.624Z" }, + { url = "https://files.pythonhosted.org/packages/31/da/0386695eef69ffae1ad726881571dfe28b41970173947e7c558d9998de0f/greenlet-3.2.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:5c9320971821a7cb77cfab8d956fa8e39cd07ca44b6070db358ceb7f8797c8c9", size = 649926, upload-time = "2025-08-07T13:53:15.251Z" }, { url = "https://files.pythonhosted.org/packages/68/88/69bf19fd4dc19981928ceacbc5fd4bb6bc2215d53199e367832e98d1d8fe/greenlet-3.2.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c60a6d84229b271d44b70fb6e5fa23781abb5d742af7b808ae3f6efd7c9c60f6", size = 651839, upload-time = "2025-08-07T13:18:30.281Z" }, { url = "https://files.pythonhosted.org/packages/19/0d/6660d55f7373b2ff8152401a83e02084956da23ae58cddbfb0b330978fe9/greenlet-3.2.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b3812d8d0c9579967815af437d96623f45c0f2ae5f04e366de62a12d83a8fb0", size = 607586, upload-time = "2025-08-07T13:18:28.544Z" }, { url = "https://files.pythonhosted.org/packages/8e/1a/c953fdedd22d81ee4629afbb38d2f9d71e37d23caace44775a3a969147d4/greenlet-3.2.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:abbf57b5a870d30c4675928c37278493044d7c14378350b3aa5d484fa65575f0", size = 1123281, upload-time = "2025-08-07T13:42:39.858Z" }, @@ -1971,6 +1992,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/49/e8/58c7f85958bda41dafea50497cbd59738c5c43dbbea5ee83d651234398f4/greenlet-3.2.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31", size = 272814, upload-time = "2025-08-07T13:15:50.011Z" }, { url = "https://files.pythonhosted.org/packages/62/dd/b9f59862e9e257a16e4e610480cfffd29e3fae018a68c2332090b53aac3d/greenlet-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945", size = 641073, upload-time = "2025-08-07T13:42:57.23Z" }, { url = "https://files.pythonhosted.org/packages/f7/0b/bc13f787394920b23073ca3b6c4a7a21396301ed75a655bcb47196b50e6e/greenlet-3.2.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:710638eb93b1fa52823aa91bf75326f9ecdfd5e0466f00789246a5280f4ba0fc", size = 655191, upload-time = "2025-08-07T13:45:29.752Z" }, + { url = "https://files.pythonhosted.org/packages/f2/d6/6adde57d1345a8d0f14d31e4ab9c23cfe8e2cd39c3baf7674b4b0338d266/greenlet-3.2.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c5111ccdc9c88f423426df3fd1811bfc40ed66264d35aa373420a34377efc98a", size = 649516, upload-time = "2025-08-07T13:53:16.314Z" }, { url = "https://files.pythonhosted.org/packages/7f/3b/3a3328a788d4a473889a2d403199932be55b1b0060f4ddd96ee7cdfcad10/greenlet-3.2.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d76383238584e9711e20ebe14db6c88ddcedc1829a9ad31a584389463b5aa504", size = 652169, upload-time = "2025-08-07T13:18:32.861Z" }, { url = "https://files.pythonhosted.org/packages/ee/43/3cecdc0349359e1a527cbf2e3e28e5f8f06d3343aaf82ca13437a9aa290f/greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671", size = 610497, upload-time = "2025-08-07T13:18:31.636Z" }, { url = "https://files.pythonhosted.org/packages/b8/19/06b6cf5d604e2c382a6f31cafafd6f33d5dea706f4db7bdab184bad2b21d/greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b", size = 1121662, upload-time = "2025-08-07T13:42:41.117Z" }, @@ -1981,6 +2003,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/22/5c/85273fd7cc388285632b0498dbbab97596e04b154933dfe0f3e68156c68c/greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0", size = 273586, upload-time = "2025-08-07T13:16:08.004Z" }, { url = "https://files.pythonhosted.org/packages/d1/75/10aeeaa3da9332c2e761e4c50d4c3556c21113ee3f0afa2cf5769946f7a3/greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f", size = 686346, upload-time = "2025-08-07T13:42:59.944Z" }, { url = "https://files.pythonhosted.org/packages/c0/aa/687d6b12ffb505a4447567d1f3abea23bd20e73a5bed63871178e0831b7a/greenlet-3.2.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c17b6b34111ea72fc5a4e4beec9711d2226285f0386ea83477cbb97c30a3f3a5", size = 699218, upload-time = "2025-08-07T13:45:30.969Z" }, + { url = "https://files.pythonhosted.org/packages/dc/8b/29aae55436521f1d6f8ff4e12fb676f3400de7fcf27fccd1d4d17fd8fecd/greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1", size = 694659, upload-time = "2025-08-07T13:53:17.759Z" }, { url = "https://files.pythonhosted.org/packages/92/2e/ea25914b1ebfde93b6fc4ff46d6864564fba59024e928bdc7de475affc25/greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735", size = 695355, upload-time = "2025-08-07T13:18:34.517Z" }, { url = "https://files.pythonhosted.org/packages/72/60/fc56c62046ec17f6b0d3060564562c64c862948c9d4bc8aa807cf5bd74f4/greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337", size = 657512, upload-time = "2025-08-07T13:18:33.969Z" }, { url = "https://files.pythonhosted.org/packages/23/6e/74407aed965a4ab6ddd93a7ded3180b730d281c77b765788419484cdfeef/greenlet-3.2.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2917bdf657f5859fbf3386b12d68ede4cf1f04c90c3a6bc1f013dd68a22e2269", size = 1612508, upload-time = "2025-11-04T12:42:23.427Z" }, @@ -2434,6 +2457,15 @@ dev = [ { name = "testing-postgresql", specifier = ">=1.3.0,<2.0.0" }, ] +[[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 = "iterators" version = "0.2.0" From 0f8c809a78f3a4debdad4628578fd2057387dcb7 Mon Sep 17 00:00:00 2001 From: rootflo-hardik Date: Tue, 24 Mar 2026 10:41:25 +0530 Subject: [PATCH 2/3] resolved review comments --- .../flo_cloud/flo_cloud/azure/blob_storage.py | 37 +++++++++++++------ .../flo_cloud/flo_cloud/cloud_storage.py | 2 +- 2 files changed, 26 insertions(+), 13 deletions(-) diff --git a/wavefront/server/packages/flo_cloud/flo_cloud/azure/blob_storage.py b/wavefront/server/packages/flo_cloud/flo_cloud/azure/blob_storage.py index fa7d4799..f956e2a3 100644 --- a/wavefront/server/packages/flo_cloud/flo_cloud/azure/blob_storage.py +++ b/wavefront/server/packages/flo_cloud/flo_cloud/azure/blob_storage.py @@ -3,7 +3,6 @@ import re from contextlib import contextmanager from datetime import datetime, timedelta, timezone -from itertools import islice from typing import IO, ContextManager, List, Optional, Tuple from azure.core.exceptions import ResourceNotFoundError @@ -59,12 +58,18 @@ def __init__( self._account_url = resolved_url self._account_name = self._parse_account_name(resolved_url) - if client_id and client_secret and tenant_id: + creds_provided = [client_id, client_secret, tenant_id] + if all(creds_provided): credential = ClientSecretCredential( tenant_id=tenant_id, client_id=client_id, client_secret=client_secret, ) + elif any(creds_provided): + raise ValueError( + 'Partial credentials provided. Supply all of client_id, ' + 'client_secret, and tenant_id, or none to use DefaultAzureCredential.' + ) else: credential = DefaultAzureCredential() @@ -191,7 +196,7 @@ def delete_file(self, bucket_name: str, file_path: str) -> None: except Exception as e: raise Exception(f'Error deleting file from Azure Blob Storage: {str(e)}') - def get_bucket_key(self, value: str): + def get_bucket_key(self, value: str) -> Tuple[str, str]: """ Parse an azure:// URL into (container_name, blob_key). @@ -286,18 +291,26 @@ def list_files( raise ValueError('page_size must be >= 1') container_client = self.client.get_container_client(bucket_name) - blobs_iterator = container_client.list_blobs(name_starts_with=prefix) + pages = container_client.list_blobs( + name_starts_with=prefix, results_per_page=page_size + ).by_page() - start_index = (page_number - 1) * page_size - end_index = start_index + page_size + 1 + # Advance to the requested page (1-based) + try: + for _ in range(page_number): + current_page = next(pages) + except StopIteration: + return [], False - page_slice = islice(blobs_iterator, start_index, end_index) - blob_names = [blob.name for blob in page_slice] + blob_names = [blob.name for blob in current_page] + + try: + next(pages) + has_next_page = True + except StopIteration: + has_next_page = False - has_next_page = len(blob_names) > page_size - if has_next_page: - return blob_names[:page_size], True - return blob_names, False + return blob_names, has_next_page except ResourceNotFoundError: raise Exception(f'Container {bucket_name} not found') diff --git a/wavefront/server/packages/flo_cloud/flo_cloud/cloud_storage.py b/wavefront/server/packages/flo_cloud/flo_cloud/cloud_storage.py index 849a6135..f2231c6b 100644 --- a/wavefront/server/packages/flo_cloud/flo_cloud/cloud_storage.py +++ b/wavefront/server/packages/flo_cloud/flo_cloud/cloud_storage.py @@ -149,7 +149,7 @@ def save_small_file( """ self.handler.save_small_file(file_content, bucket_name, key, content_type) - def file_protocol(self) -> str: + def file_protocol(self) -> Optional[str]: if self.provider == CloudProvider.AWS: return 's3' elif self.provider == CloudProvider.GCP: From ee8b209344868ebd53ebae8ca2f7ca76fdd9395e Mon Sep 17 00:00:00 2001 From: rootflo-hardik Date: Tue, 24 Mar 2026 10:53:55 +0530 Subject: [PATCH 3/3] url encoding blob key --- .../server/packages/flo_cloud/flo_cloud/azure/blob_storage.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/wavefront/server/packages/flo_cloud/flo_cloud/azure/blob_storage.py b/wavefront/server/packages/flo_cloud/flo_cloud/azure/blob_storage.py index f956e2a3..2720eabc 100644 --- a/wavefront/server/packages/flo_cloud/flo_cloud/azure/blob_storage.py +++ b/wavefront/server/packages/flo_cloud/flo_cloud/azure/blob_storage.py @@ -4,6 +4,7 @@ from contextlib import contextmanager from datetime import datetime, timedelta, timezone from typing import IO, ContextManager, List, Optional, Tuple +from urllib.parse import quote from azure.core.exceptions import ResourceNotFoundError from azure.identity import ClientSecretCredential, DefaultAzureCredential @@ -259,7 +260,8 @@ def generate_presigned_url( expiry=expiry, ) - blob_url = f'{self._account_url}/{bucket_name}/{key}?{sas_token}' + encoded_key = quote(key, safe='/') + blob_url = f'{self._account_url}/{bucket_name}/{encoded_key}?{sas_token}' return blob_url except Exception as e: raise Exception(