From cb7ce8246aa71b55231678174a7f72a6e7d4c690 Mon Sep 17 00:00:00 2001 From: Chris PC Date: Fri, 3 Jul 2026 16:39:42 +0300 Subject: [PATCH 01/11] Add support for Azure Blob Signed-off-by: Chris PC --- src/chuk_artifacts/provider_factory.py | 7 + src/chuk_artifacts/providers/__init__.py | 9 +- src/chuk_artifacts/providers/azure_blob.py | 420 +++++++++++++++++++++ 3 files changed, 434 insertions(+), 2 deletions(-) create mode 100644 src/chuk_artifacts/providers/azure_blob.py diff --git a/src/chuk_artifacts/provider_factory.py b/src/chuk_artifacts/provider_factory.py index 18607ca..713599b 100644 --- a/src/chuk_artifacts/provider_factory.py +++ b/src/chuk_artifacts/provider_factory.py @@ -9,6 +9,7 @@ • **fs**, **filesystem** - local filesystem rooted at `$ARTIFACT_FS_ROOT` • **s3** - plain AWS or any S3-compatible endpoint • **ibm_cos** - IBM COS, HMAC credentials (Signature V2) +• **azure_blob** - Azure Blob Storage with connection string or account credentials Any other value is resolved dynamically as `chuk_artifacts.providers..factory()`. @@ -82,6 +83,11 @@ def factory_for_env() -> Callable[[], AsyncContextManager]: return ibm_cos.factory() # returns the zero-arg factory callable + if provider == "azure_blob": + from .providers import azure_blob + + return azure_blob.factory() + # --------------------------------------------------------------------------- # Fallback: dynamic lookup – allows user-supplied provider implementations. # --------------------------------------------------------------------------- @@ -94,6 +100,7 @@ def factory_for_env() -> Callable[[], AsyncContextManager]: "filesystem", "s3", "ibm_cos", + "azure_blob", "vfs", "vfs-memory", "vfs-filesystem", diff --git a/src/chuk_artifacts/providers/__init__.py b/src/chuk_artifacts/providers/__init__.py index f885a15..abfb1db 100644 --- a/src/chuk_artifacts/providers/__init__.py +++ b/src/chuk_artifacts/providers/__init__.py @@ -3,9 +3,14 @@ """ Convenience re-exports so caller code can do: - from chuk_artifacts.providers import s3, ibm_cos, memory, filesystem, vfs_adapter + from chuk_artifacts.providers import s3, ibm_cos, memory, filesystem, vfs_adapter, azure_blob """ from . import s3, ibm_cos, memory, filesystem, vfs_adapter -__all__ = ["s3", "ibm_cos", "memory", "filesystem", "vfs_adapter"] +# Azure Blob Storage is optional - only import if azure-storage-blob is installed +try: + from . import azure_blob + __all__ = ["s3", "ibm_cos", "memory", "filesystem", "vfs_adapter", "azure_blob"] +except ImportError: + __all__ = ["s3", "ibm_cos", "memory", "filesystem", "vfs_adapter"] diff --git a/src/chuk_artifacts/providers/azure_blob.py b/src/chuk_artifacts/providers/azure_blob.py new file mode 100644 index 0000000..8afde33 --- /dev/null +++ b/src/chuk_artifacts/providers/azure_blob.py @@ -0,0 +1,420 @@ +# -*- coding: utf-8 -*- +# chuk_artifacts/providers/azure_blob.py +""" +Azure Blob Storage provider for artifact storage. + +Uses azure-storage-blob to provide Azure Blob Storage with S3-compatible interface. +Supports connection string or account name/key authentication. +""" + +from __future__ import annotations + +import os +import asyncio +import logging +import time +from contextlib import asynccontextmanager +from typing import Optional, Callable, AsyncContextManager, Dict, Any +from datetime import datetime, timedelta, timezone + +logger = logging.getLogger(__name__) + + +class AzureBlobAdapter: + """ + Adapter that makes Azure Blob Storage look like an S3 client. + + This adapter bridges the gap between: + - S3-style API (put_object, get_object, etc.) + - Azure Blob Storage API (upload_blob, download_blob, etc.) + """ + + def __init__( + self, + blob_service_client, + account_name: Optional[str] = None, + account_key: Optional[str] = None, + ): + """ + Initialize adapter with Azure BlobServiceClient. + + Parameters + ---------- + blob_service_client : BlobServiceClient + The Azure blob service client to wrap + account_name : str, optional + Storage account name (for SAS token generation) + account_key : str, optional + Storage account key (for SAS token generation) + """ + self._client = blob_service_client + self._account_name = account_name + self._account_key = account_key + self._closed = False + self._lock = asyncio.Lock() + + async def put_object( + self, + *, + Bucket: str, # noqa: N803 + Key: str, # noqa: N803 + Body: bytes, # noqa: N803 + ContentType: str, # noqa: N803 + Metadata: Dict[str, str], # noqa: N803 + ): + """Store object in Azure Blob Storage.""" + if self._closed: + raise RuntimeError("Client has been closed") + + from azure.core.exceptions import ResourceExistsError + from azure.storage.blob import ContentSettings + + async with self._lock: + # Get container client (S3 Bucket → Azure Container) + container_client = self._client.get_container_client(Bucket) + + # Ensure container exists + try: + await container_client.create_container() + except ResourceExistsError: + pass # Container already exists + except Exception as e: + logger.debug(f"Container creation check failed: {e}") + # Continue anyway - might have read-only access + + # Get blob client (S3 Key → Azure Blob Name) + blob_client = container_client.get_blob_client(Key) + + # Upload blob with metadata + await blob_client.upload_blob( + Body, + overwrite=True, + content_settings=ContentSettings(content_type=ContentType), + metadata=Metadata, + ) + + # Get properties for ETag + properties = await blob_client.get_blob_properties() + + return { + "ResponseMetadata": {"HTTPStatusCode": 200}, + # Azure SDK returns ETags with quotes, normalize by stripping and re-adding + "ETag": f'"{properties.etag.strip(chr(34))}"', + } + + async def get_object( + self, + *, + Bucket: str, # noqa: N803 + Key: str, # noqa: N803 + ): + """Retrieve object from Azure Blob Storage.""" + if self._closed: + raise RuntimeError("Client has been closed") + + from azure.core.exceptions import ResourceNotFoundError + + # Get container and blob clients + container_client = self._client.get_container_client(Bucket) + blob_client = container_client.get_blob_client(Key) + + # Check if blob exists + try: + # Download blob + download_stream = await blob_client.download_blob() + data = await download_stream.readall() + + # Get properties for metadata + properties = await blob_client.get_blob_properties() + + return { + "Body": data, + "ContentType": ( + properties.content_settings.content_type + if properties.content_settings + else "application/octet-stream" + ), + "Metadata": properties.metadata or {}, + "ContentLength": properties.size, + "LastModified": properties.last_modified, + # Normalize ETag quoting + "ETag": f'"{properties.etag.strip(chr(34))}"', + } + + except ResourceNotFoundError: + # Mimic S3 NoSuchKey error + error = { + "Error": { + "Code": "NoSuchKey", + "Message": "The specified key does not exist.", + "Key": Key, + "BucketName": Bucket, + } + } + raise Exception(f"NoSuchKey: {error}") + + async def head_object( + self, + *, + Bucket: str, # noqa: N803 + Key: str, # noqa: N803 + ): + """Get object metadata without body.""" + if self._closed: + raise RuntimeError("Client has been closed") + + from azure.core.exceptions import ResourceNotFoundError + + # Get container and blob clients + container_client = self._client.get_container_client(Bucket) + blob_client = container_client.get_blob_client(Key) + + try: + # Get properties without downloading + properties = await blob_client.get_blob_properties() + + return { + "ContentType": ( + properties.content_settings.content_type + if properties.content_settings + else "application/octet-stream" + ), + "Metadata": properties.metadata or {}, + "ContentLength": properties.size, + "LastModified": properties.last_modified, + # Normalize ETag quoting + "ETag": f'"{properties.etag.strip(chr(34))}"', + } + + except ResourceNotFoundError: + raise Exception(f"NoSuchKey: {Key}") + + async def head_bucket(self, *, Bucket: str): # noqa: N803 + """Check if container exists.""" + if self._closed: + raise RuntimeError("Client has been closed") + + from azure.core.exceptions import ResourceNotFoundError + + container_client = self._client.get_container_client(Bucket) + + try: + await container_client.get_container_properties() + return {"ResponseMetadata": {"HTTPStatusCode": 200}} + except ResourceNotFoundError: + return {"ResponseMetadata": {"HTTPStatusCode": 404}} + + async def delete_object( + self, + *, + Bucket: str, # noqa: N803 + Key: str, # noqa: N803 + ): + """Delete object from Azure Blob Storage.""" + if self._closed: + raise RuntimeError("Client has been closed") + + from azure.core.exceptions import ResourceNotFoundError + + # Get container and blob clients + container_client = self._client.get_container_client(Bucket) + blob_client = container_client.get_blob_client(Key) + + # Delete blob (don't error if doesn't exist - S3 behavior) + try: + await blob_client.delete_blob() + except ResourceNotFoundError: + pass # S3 delete is idempotent - blob not found is OK + + return {"ResponseMetadata": {"HTTPStatusCode": 204}} + + async def list_objects_v2( + self, + *, + Bucket: str, # noqa: N803 + Prefix: str = "", # noqa: N803 + MaxKeys: int = 1000, # noqa: N803 + ): + """List objects with optional prefix filtering.""" + if self._closed: + raise RuntimeError("Client has been closed") + + from azure.core.exceptions import ResourceNotFoundError + + # Get container client + container_client = self._client.get_container_client(Bucket) + + try: + # List blobs with prefix + contents = [] + + async for blob in container_client.list_blobs( + name_starts_with=Prefix if Prefix else None + ): + contents.append( + { + "Key": blob.name, + "Size": blob.size, + "LastModified": blob.last_modified, + # Normalize ETag quoting + "ETag": f'"{blob.etag.strip(chr(34))}"', + } + ) + + # Limit results + if len(contents) >= MaxKeys: + break + + return { + "Contents": contents, + "KeyCount": len(contents), + "IsTruncated": len(contents) >= MaxKeys, + } + + except ResourceNotFoundError: + # Container doesn't exist - return empty + return { + "Contents": [], + "KeyCount": 0, + "IsTruncated": False, + } + + async def generate_presigned_url( + self, + operation: str, + *, + Params: Dict[str, str], # noqa: N803 + ExpiresIn: int, # noqa: N803 + ) -> str: + """ + Generate presigned URL using Azure SAS token. + + Note: Requires account_name and account_key to be set. + """ + if self._closed: + raise RuntimeError("Client has been closed") + + if not (self._account_name and self._account_key): + raise RuntimeError( + "Presigned URL generation requires AZURE_STORAGE_ACCOUNT_NAME " + "and AZURE_STORAGE_ACCOUNT_KEY to be set" + ) + + from azure.storage.blob import generate_blob_sas, BlobSasPermissions + + bucket = Params["Bucket"] + key = Params["Key"] + + # Map S3 operations to Azure permissions + if operation in ("get_object", "head_object"): + permission = BlobSasPermissions(read=True) + elif operation == "put_object": + permission = BlobSasPermissions(write=True, create=True) + else: + raise ValueError(f"Unsupported operation: {operation}") + + # Generate SAS token + sas_token = generate_blob_sas( + account_name=self._account_name, + container_name=bucket, + blob_name=key, + account_key=self._account_key, + permission=permission, + expiry=datetime.now(timezone.utc) + timedelta(seconds=ExpiresIn), + ) + + # Construct full URL + blob_url = ( + f"https://{self._account_name}.blob.core.windows.net/{bucket}/{key}" + ) + return f"{blob_url}?{sas_token}" + + async def close(self): + """Clean up resources.""" + if not self._closed: + self._closed = True + await self._client.close() + + +def factory( + *, + connection_string: Optional[str] = None, + account_name: Optional[str] = None, + account_key: Optional[str] = None, +) -> Callable[[], AsyncContextManager]: + """ + Create Azure Blob Storage client factory. + + Parameters + ---------- + connection_string : str, optional + Azure storage connection string (preferred method) + account_name : str, optional + Storage account name (alternative to connection_string) + account_key : str, optional + Storage account key (alternative to connection_string) + + Returns + ------- + Callable[[], AsyncContextManager] + Factory function that returns Azure Blob client context managers + + Environment Variables + --------------------- + - AZURE_STORAGE_CONNECTION_STRING: Full connection string + - AZURE_STORAGE_ACCOUNT_NAME: Storage account name + - AZURE_STORAGE_ACCOUNT_KEY: Storage account key + """ + # Get configuration from parameters or environment + connection_string = connection_string or os.getenv( + "AZURE_STORAGE_CONNECTION_STRING" + ) + account_name = account_name or os.getenv("AZURE_STORAGE_ACCOUNT_NAME") + account_key = account_key or os.getenv("AZURE_STORAGE_ACCOUNT_KEY") + + # Validate credentials + if not connection_string and not (account_name and account_key): + raise RuntimeError( + "Azure credentials missing. Set AZURE_STORAGE_CONNECTION_STRING " + "or AZURE_STORAGE_ACCOUNT_NAME + AZURE_STORAGE_ACCOUNT_KEY " + "environment variables." + ) + + @asynccontextmanager + async def _ctx(): + from azure.storage.blob.aio import BlobServiceClient + + # Create client based on credential type + if connection_string: + client = BlobServiceClient.from_connection_string(connection_string) + # Extract account name from connection string if needed + if not account_name: + try: + for part in connection_string.split(";"): + if part.startswith("AccountName="): + extracted_name = part.split("=", 1)[1] + break + else: + extracted_name = None + except Exception: + extracted_name = None + else: + extracted_name = account_name + else: + account_url = f"https://{account_name}.blob.core.windows.net" + client = BlobServiceClient( + account_url=account_url, credential=account_key + ) + extracted_name = account_name + + # Wrap in adapter + adapter = AzureBlobAdapter( + client, account_name=extracted_name, account_key=account_key + ) + + try: + yield adapter + finally: + await adapter.close() + + return _ctx From 944fece9420e681ef9bba4780bfdb66975ced3f2 Mon Sep 17 00:00:00 2001 From: Chris PC Date: Fri, 3 Jul 2026 16:40:13 +0300 Subject: [PATCH 02/11] Add tests and examples Signed-off-by: Chris PC --- examples/11_azure_blob_quickstart.py | 110 ++++++ tests/providers/test_azure_blob_provider.py | 369 ++++++++++++++++++++ 2 files changed, 479 insertions(+) create mode 100644 examples/11_azure_blob_quickstart.py create mode 100644 tests/providers/test_azure_blob_provider.py diff --git a/examples/11_azure_blob_quickstart.py b/examples/11_azure_blob_quickstart.py new file mode 100644 index 0000000..6f449c1 --- /dev/null +++ b/examples/11_azure_blob_quickstart.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Azure Blob Storage Quickstart Example + +Demonstrates using Azure Blob Storage as the artifact backend. +""" + +import asyncio +import os + + +async def azure_blob_example(): + """Example using Azure Blob Storage provider.""" + from chuk_artifacts import ArtifactStore + + # Configure Azure Blob Storage + # Option 1: Connection String (recommended) + os.environ["ARTIFACT_PROVIDER"] = "azure_blob" + os.environ["AZURE_STORAGE_CONNECTION_STRING"] = ( + "DefaultEndpointsProtocol=https;" + "AccountName=your_account;" + "AccountKey=your_key_here;" + "EndpointSuffix=core.windows.net" + ) + + # OR Option 2: Account Name + Key + # os.environ["AZURE_STORAGE_ACCOUNT_NAME"] = "your_account" + # os.environ["AZURE_STORAGE_ACCOUNT_KEY"] = "your_key_here" + + os.environ["ARTIFACT_BUCKET"] = "my-artifacts-container" + os.environ["SESSION_PROVIDER"] = "memory" + + # Create store + store = ArtifactStore( + storage_provider="azure_blob", + session_provider="memory", + bucket="my-artifacts-container" + ) + + print("✓ Connected to Azure Blob Storage") + + # Store an artifact + artifact_id = await store.store( + data=b"Hello from Azure!", + mime="text/plain", + summary="Test artifact", + session_id="demo-session" + ) + print(f"✓ Stored artifact: {artifact_id}") + + # Retrieve it + data = await store.retrieve(artifact_id) + print(f"✓ Retrieved: {data.decode('utf-8')}") + + # List files + files = await store.list_files(session_id="demo-session") + print(f"✓ Found {len(files)} file(s) in session") + + # Delete + await store.delete(artifact_id) + print("✓ Deleted artifact") + + print("\n✅ Azure Blob Storage example complete!") + + +async def azure_with_programmatic_config(): + """Example using programmatic configuration.""" + from chuk_artifacts import ArtifactStore + + # Programmatic configuration (no environment variables needed) + store = ArtifactStore( + storage_provider="azure_blob", + session_provider="memory", + bucket="my-container", + # Azure credentials can be passed via environment or connection string + ) + + print("✓ Created store with programmatic config") + + +async def main(): + """Run examples.""" + print("Azure Blob Storage Examples") + print("=" * 50) + + # Check if credentials are set + if not (os.getenv("AZURE_STORAGE_CONNECTION_STRING") or + (os.getenv("AZURE_STORAGE_ACCOUNT_NAME") and + os.getenv("AZURE_STORAGE_ACCOUNT_KEY"))): + print("\n⚠️ Azure credentials not configured") + print("Set AZURE_STORAGE_CONNECTION_STRING or") + print("AZURE_STORAGE_ACCOUNT_NAME + AZURE_STORAGE_ACCOUNT_KEY") + print("\nTo run this example:") + print(' export AZURE_STORAGE_CONNECTION_STRING="..."') + print(' python examples/azure_blob_quickstart.py') + return + + try: + await azure_blob_example() + except Exception as e: + print(f"\n❌ Error: {e}") + print("\nMake sure:") + print("1. Azure credentials are correct") + print("2. Container exists or you have permission to create it") + print("3. azure-storage-blob package is installed") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tests/providers/test_azure_blob_provider.py b/tests/providers/test_azure_blob_provider.py new file mode 100644 index 0000000..233abbc --- /dev/null +++ b/tests/providers/test_azure_blob_provider.py @@ -0,0 +1,369 @@ +# -*- coding: utf-8 -*- +""" +Unit tests for Azure Blob Storage provider with mocked Azure SDK. + +These tests verify the provider implementation without requiring a real Azure account. +""" + +import pytest +from unittest.mock import AsyncMock, MagicMock, patch +from datetime import datetime, timezone + + +@pytest.fixture +def mock_blob_properties(): + """Mock blob properties returned by Azure.""" + props = MagicMock() + props.etag = "abc123" + props.size = 100 + props.last_modified = datetime.now(timezone.utc) + props.metadata = {"test": "value"} + props.content_settings = MagicMock() + props.content_settings.content_type = "text/plain" + return props + + +@pytest.fixture +def mock_blob_client(mock_blob_properties): + """Mock Azure BlobClient.""" + mock_client = AsyncMock() + mock_client.upload_blob = AsyncMock() + mock_client.download_blob = AsyncMock() + mock_client.get_blob_properties = AsyncMock(return_value=mock_blob_properties) + mock_client.delete_blob = AsyncMock() + return mock_client + + +@pytest.fixture +def mock_container_client(mock_blob_client): + """Mock Azure ContainerClient.""" + mock_client = AsyncMock() + mock_client.create_container = AsyncMock() + mock_client.get_blob_client = AsyncMock(return_value=mock_blob_client) + mock_client.get_container_properties = AsyncMock() + + # Mock list_blobs to return async iterator + mock_blob = MagicMock() + mock_blob.name = "test/file.txt" + mock_blob.size = 100 + mock_blob.last_modified = datetime.now(timezone.utc) + mock_blob.etag = "abc123" + + async def async_iter(): + yield mock_blob + + mock_client.list_blobs = MagicMock(return_value=async_iter()) + + return mock_client + + +@pytest.fixture +def mock_blob_service_client(mock_container_client): + """Mock Azure BlobServiceClient.""" + mock_client = AsyncMock() + mock_client.get_container_client = AsyncMock(return_value=mock_container_client) + mock_client.close = AsyncMock() + return mock_client + + +@pytest.mark.asyncio +async def test_factory_with_connection_string(): + """Test factory initialization with connection string.""" + connection_string = ( + "DefaultEndpointsProtocol=https;" + "AccountName=testaccount;" + "AccountKey=dGVzdGtleQ==;" + "EndpointSuffix=core.windows.net" + ) + + with patch.dict("os.environ", {"AZURE_STORAGE_CONNECTION_STRING": connection_string}): + with patch("azure.storage.blob.aio.BlobServiceClient") as mock_client_class: + mock_client = AsyncMock() + mock_client.close = AsyncMock() + mock_client_class.from_connection_string.return_value = mock_client + + # Import after patching + import sys + if "chuk_artifacts.providers.azure_blob" in sys.modules: + del sys.modules["chuk_artifacts.providers.azure_blob"] + + from chuk_artifacts.providers.azure_blob import factory + + # Create factory + client_factory = factory() + + # Use context manager + async with client_factory() as client: + assert client is not None + assert not client._closed + + +@pytest.mark.asyncio +async def test_factory_with_account_credentials(): + """Test factory initialization with account name and key.""" + with patch.dict("os.environ", { + "AZURE_STORAGE_ACCOUNT_NAME": "testaccount", + "AZURE_STORAGE_ACCOUNT_KEY": "dGVzdGtleQ==" + }): + with patch("azure.storage.blob.aio.BlobServiceClient") as mock_client_class: + mock_client = AsyncMock() + mock_client.close = AsyncMock() + mock_client_class.return_value = mock_client + + from chuk_artifacts.providers.azure_blob import factory + + # Create factory + client_factory = factory() + + # Use context manager + async with client_factory() as client: + assert client is not None + + +@pytest.mark.asyncio +async def test_factory_missing_credentials(): + """Test factory fails with missing credentials.""" + with patch.dict("os.environ", {}, clear=True): + from chuk_artifacts.providers.azure_blob import factory + + with pytest.raises(RuntimeError, match="Azure credentials missing"): + factory() + + +@pytest.mark.asyncio +async def test_put_object(mock_blob_service_client, mock_blob_properties): + """Test uploading an object.""" + from chuk_artifacts.providers.azure_blob import AzureBlobAdapter + + adapter = AzureBlobAdapter( + mock_blob_service_client, + account_name="testaccount", + account_key="testkey" + ) + + result = await adapter.put_object( + Bucket="test-container", + Key="test-file.txt", + Body=b"test content", + ContentType="text/plain", + Metadata={"author": "test"} + ) + + # Verify result + assert result["ResponseMetadata"]["HTTPStatusCode"] == 200 + assert "ETag" in result + + # Verify Azure SDK was called correctly + mock_blob_service_client.get_container_client.assert_called_with("test-container") + + +@pytest.mark.asyncio +async def test_get_object(mock_blob_service_client): + """Test downloading an object.""" + from chuk_artifacts.providers.azure_blob import AzureBlobAdapter + + # Setup mock download + mock_download_stream = AsyncMock() + mock_download_stream.readall = AsyncMock(return_value=b"test content") + + container_client = mock_blob_service_client.get_container_client.return_value + blob_client = container_client.get_blob_client.return_value + blob_client.download_blob = AsyncMock(return_value=mock_download_stream) + + adapter = AzureBlobAdapter( + mock_blob_service_client, + account_name="testaccount", + account_key="testkey" + ) + + result = await adapter.get_object( + Bucket="test-container", + Key="test-file.txt" + ) + + # Verify result + assert result["Body"] == b"test content" + assert result["ContentType"] == "text/plain" + assert result["Metadata"] == {"test": "value"} + assert result["ContentLength"] == 100 + + +@pytest.mark.asyncio +async def test_get_object_not_found(mock_blob_service_client): + """Test downloading non-existent object.""" + from chuk_artifacts.providers.azure_blob import AzureBlobAdapter + from azure.core.exceptions import ResourceNotFoundError + + container_client = mock_blob_service_client.get_container_client.return_value + blob_client = container_client.get_blob_client.return_value + blob_client.download_blob = AsyncMock(side_effect=ResourceNotFoundError("Not found")) + + adapter = AzureBlobAdapter( + mock_blob_service_client, + account_name="testaccount", + account_key="testkey" + ) + + with pytest.raises(Exception, match="NoSuchKey"): + await adapter.get_object( + Bucket="test-container", + Key="nonexistent.txt" + ) + + +@pytest.mark.asyncio +async def test_delete_object(mock_blob_service_client): + """Test deleting an object.""" + from chuk_artifacts.providers.azure_blob import AzureBlobAdapter + + adapter = AzureBlobAdapter( + mock_blob_service_client, + account_name="testaccount", + account_key="testkey" + ) + + result = await adapter.delete_object( + Bucket="test-container", + Key="test-file.txt" + ) + + # Verify result + assert result["ResponseMetadata"]["HTTPStatusCode"] == 204 + + # Verify Azure SDK was called + container_client = mock_blob_service_client.get_container_client.return_value + blob_client = container_client.get_blob_client.return_value + blob_client.delete_blob.assert_called_once() + + +@pytest.mark.asyncio +async def test_list_objects_v2(mock_blob_service_client): + """Test listing objects.""" + from chuk_artifacts.providers.azure_blob import AzureBlobAdapter + + adapter = AzureBlobAdapter( + mock_blob_service_client, + account_name="testaccount", + account_key="testkey" + ) + + result = await adapter.list_objects_v2( + Bucket="test-container", + Prefix="test/", + MaxKeys=1000 + ) + + # Verify result + assert result["KeyCount"] == 1 + assert len(result["Contents"]) == 1 + assert result["Contents"][0]["Key"] == "test/file.txt" + assert result["Contents"][0]["Size"] == 100 + assert "ETag" in result["Contents"][0] + + +@pytest.mark.asyncio +async def test_head_object(mock_blob_service_client): + """Test getting object metadata.""" + from chuk_artifacts.providers.azure_blob import AzureBlobAdapter + + adapter = AzureBlobAdapter( + mock_blob_service_client, + account_name="testaccount", + account_key="testkey" + ) + + result = await adapter.head_object( + Bucket="test-container", + Key="test-file.txt" + ) + + # Verify result + assert result["ContentType"] == "text/plain" + assert result["ContentLength"] == 100 + assert result["Metadata"] == {"test": "value"} + + +@pytest.mark.asyncio +async def test_generate_presigned_url(mock_blob_service_client): + """Test generating presigned URL.""" + from chuk_artifacts.providers.azure_blob import AzureBlobAdapter + + with patch("azure.storage.blob.generate_blob_sas") as mock_sas: + mock_sas.return_value = "sig=abc123&st=2024-01-01&se=2024-01-02" + + adapter = AzureBlobAdapter( + mock_blob_service_client, + account_name="testaccount", + account_key="testkey" + ) + + url = await adapter.generate_presigned_url( + operation="get_object", + Params={"Bucket": "test-container", "Key": "test-file.txt"}, + ExpiresIn=3600 + ) + + # Verify URL structure + assert "testaccount.blob.core.windows.net" in url + assert "test-container" in url + assert "test-file.txt" in url + assert "sig=" in url + + +@pytest.mark.asyncio +async def test_generate_presigned_url_without_credentials(mock_blob_service_client): + """Test generating presigned URL fails without credentials.""" + from chuk_artifacts.providers.azure_blob import AzureBlobAdapter + + adapter = AzureBlobAdapter( + mock_blob_service_client, + account_name=None, + account_key=None + ) + + with pytest.raises(RuntimeError, match="Presigned URL generation requires"): + await adapter.generate_presigned_url( + operation="get_object", + Params={"Bucket": "test-container", "Key": "test-file.txt"}, + ExpiresIn=3600 + ) + + +@pytest.mark.asyncio +async def test_close(mock_blob_service_client): + """Test closing the adapter.""" + from chuk_artifacts.providers.azure_blob import AzureBlobAdapter + + adapter = AzureBlobAdapter( + mock_blob_service_client, + account_name="testaccount", + account_key="testkey" + ) + + await adapter.close() + + assert adapter._closed is True + mock_blob_service_client.close.assert_called_once() + + +@pytest.mark.asyncio +async def test_operations_fail_after_close(mock_blob_service_client): + """Test operations fail after closing.""" + from chuk_artifacts.providers.azure_blob import AzureBlobAdapter + + adapter = AzureBlobAdapter( + mock_blob_service_client, + account_name="testaccount", + account_key="testkey" + ) + + await adapter.close() + + with pytest.raises(RuntimeError, match="Client has been closed"): + await adapter.put_object( + Bucket="test", + Key="test", + Body=b"data", + ContentType="text/plain", + Metadata={} + ) From 66b7e9c7e0bf96738815e531d6dd91c92cbdfd16 Mon Sep 17 00:00:00 2001 From: Chris PC Date: Fri, 3 Jul 2026 16:40:26 +0300 Subject: [PATCH 03/11] Update readme and dependencies Signed-off-by: Chris PC --- README.md | 29 +++++- pyproject.toml | 1 + uv.lock | 278 +++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 306 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 5053be8..00cf301 100644 --- a/README.md +++ b/README.md @@ -206,14 +206,25 @@ This is fundamentally more powerful than raw storage. pip install chuk-artifacts ``` +**With optional cloud storage providers:** + +```bash +# Azure Blob Storage +pip install chuk-artifacts[azure] + +# All extras together +pip install chuk-artifacts[azure] +``` + **Dependencies:** - `chuk-virtual-fs` - VFS layer (automatically installed) - `chuk-sessions` - Session management (automatically installed) **Optional:** - `redis` - For Redis session provider -- `boto3` - For S3 storage backend -- `ibm-cos-sdk` - For IBM Cloud Object Storage +- `boto3` - For S3 storage backend (automatically installed) +- `ibm-cos-sdk` - For IBM Cloud Object Storage (automatically installed) +- `azure-storage-blob` - For Azure Blob Storage (install with `[azure]` extra) --- @@ -716,6 +727,14 @@ export ARTIFACT_PROVIDER=vfs-s3 export AWS_ACCESS_KEY_ID=your_key export AWS_SECRET_ACCESS_KEY=your_secret export AWS_DEFAULT_REGION=us-east-1 + +# Azure Blob Storage (persistent cloud storage) +export ARTIFACT_PROVIDER=azure_blob +export AZURE_STORAGE_CONNECTION_STRING="DefaultEndpointsProtocol=https;AccountName=...;AccountKey=...;EndpointSuffix=core.windows.net" +# OR use account credentials: +export AZURE_STORAGE_ACCOUNT_NAME=your_account +export AZURE_STORAGE_ACCOUNT_KEY=your_key +export ARTIFACT_BUCKET=your-container ``` ### Session Providers @@ -792,6 +811,12 @@ CHUK Artifacts is designed for high performance: - Parallel uploads for large files - Battle-tested at scale +**Azure Blob Storage Provider:** +- Native async Azure SDK +- Supports connection string or account credentials +- Compatible with all Azure storage tiers +- Presigned URL support via SAS tokens + **SQLite Provider:** - Fast for small to medium workspaces - Queryable storage with indexes diff --git a/pyproject.toml b/pyproject.toml index 21c00e0..1f34cf1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,7 @@ dependencies = [ "chuk-virtual-fs>=0.5.1", "dotenv>=0.9.9", "asyncio>=3.4.3", + "azure-storage-blob>=12.19.0", ] [project.optional-dependencies] diff --git a/uv.lock b/uv.lock index ee0702a..6af78e2 100644 --- a/uv.lock +++ b/uv.lock @@ -233,6 +233,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, ] +[[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 = "boto3" version = "1.40.61" @@ -270,6 +298,165 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, ] +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" }, + { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" }, + { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" }, + { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" }, + { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" }, + { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" }, + { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" }, + { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" }, + { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" }, + { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" }, + { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" }, + { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" }, + { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" }, + { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" }, + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + [[package]] name = "chuk-artifacts" version = "0.11.3" @@ -277,6 +464,7 @@ source = { editable = "." } dependencies = [ { name = "aioboto3" }, { name = "asyncio" }, + { name = "azure-storage-blob" }, { name = "chuk-sessions" }, { name = "chuk-virtual-fs" }, { name = "dotenv" }, @@ -305,6 +493,7 @@ dev = [ requires-dist = [ { name = "aioboto3", specifier = ">=14.3.0" }, { name = "asyncio", specifier = ">=3.4.3" }, + { name = "azure-storage-blob", specifier = ">=12.19.0" }, { name = "chuk-sessions", specifier = ">=0.6.1" }, { name = "chuk-virtual-fs", specifier = ">=0.5.1" }, { name = "dotenv", specifier = ">=0.9.9" }, @@ -467,6 +656,62 @@ toml = [ { name = "tomli", marker = "python_full_version <= '3.11'" }, ] +[[package]] +name = "cryptography" +version = "49.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" }, + { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" }, + { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" }, + { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" }, + { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" }, + { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" }, + { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" }, + { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" }, + { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" }, + { url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" }, + { url = "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892, upload-time = "2026-06-12T20:02:10.735Z" }, + { url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" }, + { url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" }, + { url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" }, + { url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" }, + { url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" }, + { url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" }, + { url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" }, + { url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153, upload-time = "2026-06-12T20:01:39.059Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" }, + { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" }, + { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" }, + { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" }, + { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" }, + { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" }, + { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" }, + { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" }, + { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" }, + { url = "https://files.pythonhosted.org/packages/63/d3/4a83af35d65e3fad632c926fad684c193ea4398569ccb0bbbc7fe8f5dc9a/cryptography-49.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b", size = 3993685, upload-time = "2026-06-12T20:02:14.883Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a7/f9dac0ab7f80368c56993a7bf638ef9935f825c91902798481fac0898138/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838", size = 4676239, upload-time = "2026-06-12T20:02:28.793Z" }, + { url = "https://files.pythonhosted.org/packages/d7/70/2ba3769dd0ae167e2f33dfa9592d45db6ff9a61d62ca1a5b3d1bdd09068f/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5", size = 4715584, upload-time = "2026-06-12T20:01:27.495Z" }, + { url = "https://files.pythonhosted.org/packages/94/64/2923570ac1c0bd3a737aa366ac3abbbbde273042308b8cde95e2364a6e6a/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615", size = 4675885, upload-time = "2026-06-12T20:01:55.49Z" }, + { url = "https://files.pythonhosted.org/packages/ab/f8/614dc7e051418cfe53d55173c1e24c6b0085e89996fe90508c2fdf769aef/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6", size = 4715449, upload-time = "2026-06-12T20:02:05.469Z" }, + { url = "https://files.pythonhosted.org/packages/aa/50/a9caea39ad19c431c1a3f8a31114df65b260cdfe67786b6c7e7c040c4c44/cryptography-49.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6", size = 3783731, upload-time = "2026-06-12T20:02:43.319Z" }, +] + [[package]] name = "csrf" version = "0.1b1" @@ -647,6 +892,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[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 = "jmespath" version = "1.1.0" @@ -1031,6 +1285,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, ] +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + [[package]] name = "pydantic" version = "2.13.3" @@ -1288,6 +1551,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/74/3a/95deec7db1eb53979973ebd156f3369a72732208d1391cd2e5d127062a32/redis-7.4.0-py3-none-any.whl", hash = "sha256:a9c74a5c893a5ef8455a5adb793a31bb70feb821c86eccb62eebef5a19c429ec", size = 409772, upload-time = "2026-03-24T09:14:35.968Z" }, ] +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + [[package]] name = "ruff" version = "0.15.11" From 4b4bf29cc9ad6e874f16b696343268b58c624b36 Mon Sep 17 00:00:00 2001 From: Chris PC Date: Fri, 3 Jul 2026 16:47:08 +0300 Subject: [PATCH 04/11] Fix linter errors Signed-off-by: Chris PC --- examples/11_azure_blob_quickstart.py | 2 +- src/chuk_artifacts/providers/azure_blob.py | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/examples/11_azure_blob_quickstart.py b/examples/11_azure_blob_quickstart.py index 6f449c1..b0d2e04 100644 --- a/examples/11_azure_blob_quickstart.py +++ b/examples/11_azure_blob_quickstart.py @@ -69,7 +69,7 @@ async def azure_with_programmatic_config(): from chuk_artifacts import ArtifactStore # Programmatic configuration (no environment variables needed) - store = ArtifactStore( + _ = ArtifactStore( storage_provider="azure_blob", session_provider="memory", bucket="my-container", diff --git a/src/chuk_artifacts/providers/azure_blob.py b/src/chuk_artifacts/providers/azure_blob.py index 8afde33..3660af5 100644 --- a/src/chuk_artifacts/providers/azure_blob.py +++ b/src/chuk_artifacts/providers/azure_blob.py @@ -12,9 +12,8 @@ import os import asyncio import logging -import time from contextlib import asynccontextmanager -from typing import Optional, Callable, AsyncContextManager, Dict, Any +from typing import Optional, Callable, AsyncContextManager, Dict from datetime import datetime, timedelta, timezone logger = logging.getLogger(__name__) From 4b5bcf49cf421d7b3f7f3a0ec1887271ef546859 Mon Sep 17 00:00:00 2001 From: Chris PC Date: Fri, 3 Jul 2026 16:57:26 +0300 Subject: [PATCH 05/11] Fix ruffs Signed-off-by: Chris PC --- examples/11_azure_blob_quickstart.py | 16 ++-- src/chuk_artifacts/providers/__init__.py | 1 + src/chuk_artifacts/providers/azure_blob.py | 8 +- tests/providers/test_azure_blob_provider.py | 92 ++++++++------------- 4 files changed, 46 insertions(+), 71 deletions(-) diff --git a/examples/11_azure_blob_quickstart.py b/examples/11_azure_blob_quickstart.py index b0d2e04..c871868 100644 --- a/examples/11_azure_blob_quickstart.py +++ b/examples/11_azure_blob_quickstart.py @@ -35,7 +35,7 @@ async def azure_blob_example(): store = ArtifactStore( storage_provider="azure_blob", session_provider="memory", - bucket="my-artifacts-container" + bucket="my-artifacts-container", ) print("✓ Connected to Azure Blob Storage") @@ -45,7 +45,7 @@ async def azure_blob_example(): data=b"Hello from Azure!", mime="text/plain", summary="Test artifact", - session_id="demo-session" + session_id="demo-session", ) print(f"✓ Stored artifact: {artifact_id}") @@ -85,15 +85,19 @@ async def main(): print("=" * 50) # Check if credentials are set - if not (os.getenv("AZURE_STORAGE_CONNECTION_STRING") or - (os.getenv("AZURE_STORAGE_ACCOUNT_NAME") and - os.getenv("AZURE_STORAGE_ACCOUNT_KEY"))): + if not ( + os.getenv("AZURE_STORAGE_CONNECTION_STRING") + or ( + os.getenv("AZURE_STORAGE_ACCOUNT_NAME") + and os.getenv("AZURE_STORAGE_ACCOUNT_KEY") + ) + ): print("\n⚠️ Azure credentials not configured") print("Set AZURE_STORAGE_CONNECTION_STRING or") print("AZURE_STORAGE_ACCOUNT_NAME + AZURE_STORAGE_ACCOUNT_KEY") print("\nTo run this example:") print(' export AZURE_STORAGE_CONNECTION_STRING="..."') - print(' python examples/azure_blob_quickstart.py') + print(" python examples/azure_blob_quickstart.py") return try: diff --git a/src/chuk_artifacts/providers/__init__.py b/src/chuk_artifacts/providers/__init__.py index abfb1db..bc396db 100644 --- a/src/chuk_artifacts/providers/__init__.py +++ b/src/chuk_artifacts/providers/__init__.py @@ -11,6 +11,7 @@ # Azure Blob Storage is optional - only import if azure-storage-blob is installed try: from . import azure_blob + __all__ = ["s3", "ibm_cos", "memory", "filesystem", "vfs_adapter", "azure_blob"] except ImportError: __all__ = ["s3", "ibm_cos", "memory", "filesystem", "vfs_adapter"] diff --git a/src/chuk_artifacts/providers/azure_blob.py b/src/chuk_artifacts/providers/azure_blob.py index 3660af5..450b616 100644 --- a/src/chuk_artifacts/providers/azure_blob.py +++ b/src/chuk_artifacts/providers/azure_blob.py @@ -323,9 +323,7 @@ async def generate_presigned_url( ) # Construct full URL - blob_url = ( - f"https://{self._account_name}.blob.core.windows.net/{bucket}/{key}" - ) + blob_url = f"https://{self._account_name}.blob.core.windows.net/{bucket}/{key}" return f"{blob_url}?{sas_token}" async def close(self): @@ -401,9 +399,7 @@ async def _ctx(): extracted_name = account_name else: account_url = f"https://{account_name}.blob.core.windows.net" - client = BlobServiceClient( - account_url=account_url, credential=account_key - ) + client = BlobServiceClient(account_url=account_url, credential=account_key) extracted_name = account_name # Wrap in adapter diff --git a/tests/providers/test_azure_blob_provider.py b/tests/providers/test_azure_blob_provider.py index 233abbc..08112d7 100644 --- a/tests/providers/test_azure_blob_provider.py +++ b/tests/providers/test_azure_blob_provider.py @@ -76,7 +76,9 @@ async def test_factory_with_connection_string(): "EndpointSuffix=core.windows.net" ) - with patch.dict("os.environ", {"AZURE_STORAGE_CONNECTION_STRING": connection_string}): + with patch.dict( + "os.environ", {"AZURE_STORAGE_CONNECTION_STRING": connection_string} + ): with patch("azure.storage.blob.aio.BlobServiceClient") as mock_client_class: mock_client = AsyncMock() mock_client.close = AsyncMock() @@ -84,6 +86,7 @@ async def test_factory_with_connection_string(): # Import after patching import sys + if "chuk_artifacts.providers.azure_blob" in sys.modules: del sys.modules["chuk_artifacts.providers.azure_blob"] @@ -101,10 +104,13 @@ async def test_factory_with_connection_string(): @pytest.mark.asyncio async def test_factory_with_account_credentials(): """Test factory initialization with account name and key.""" - with patch.dict("os.environ", { - "AZURE_STORAGE_ACCOUNT_NAME": "testaccount", - "AZURE_STORAGE_ACCOUNT_KEY": "dGVzdGtleQ==" - }): + with patch.dict( + "os.environ", + { + "AZURE_STORAGE_ACCOUNT_NAME": "testaccount", + "AZURE_STORAGE_ACCOUNT_KEY": "dGVzdGtleQ==", + }, + ): with patch("azure.storage.blob.aio.BlobServiceClient") as mock_client_class: mock_client = AsyncMock() mock_client.close = AsyncMock() @@ -136,9 +142,7 @@ async def test_put_object(mock_blob_service_client, mock_blob_properties): from chuk_artifacts.providers.azure_blob import AzureBlobAdapter adapter = AzureBlobAdapter( - mock_blob_service_client, - account_name="testaccount", - account_key="testkey" + mock_blob_service_client, account_name="testaccount", account_key="testkey" ) result = await adapter.put_object( @@ -146,7 +150,7 @@ async def test_put_object(mock_blob_service_client, mock_blob_properties): Key="test-file.txt", Body=b"test content", ContentType="text/plain", - Metadata={"author": "test"} + Metadata={"author": "test"}, ) # Verify result @@ -171,15 +175,10 @@ async def test_get_object(mock_blob_service_client): blob_client.download_blob = AsyncMock(return_value=mock_download_stream) adapter = AzureBlobAdapter( - mock_blob_service_client, - account_name="testaccount", - account_key="testkey" + mock_blob_service_client, account_name="testaccount", account_key="testkey" ) - result = await adapter.get_object( - Bucket="test-container", - Key="test-file.txt" - ) + result = await adapter.get_object(Bucket="test-container", Key="test-file.txt") # Verify result assert result["Body"] == b"test content" @@ -196,19 +195,16 @@ async def test_get_object_not_found(mock_blob_service_client): container_client = mock_blob_service_client.get_container_client.return_value blob_client = container_client.get_blob_client.return_value - blob_client.download_blob = AsyncMock(side_effect=ResourceNotFoundError("Not found")) + blob_client.download_blob = AsyncMock( + side_effect=ResourceNotFoundError("Not found") + ) adapter = AzureBlobAdapter( - mock_blob_service_client, - account_name="testaccount", - account_key="testkey" + mock_blob_service_client, account_name="testaccount", account_key="testkey" ) with pytest.raises(Exception, match="NoSuchKey"): - await adapter.get_object( - Bucket="test-container", - Key="nonexistent.txt" - ) + await adapter.get_object(Bucket="test-container", Key="nonexistent.txt") @pytest.mark.asyncio @@ -217,15 +213,10 @@ async def test_delete_object(mock_blob_service_client): from chuk_artifacts.providers.azure_blob import AzureBlobAdapter adapter = AzureBlobAdapter( - mock_blob_service_client, - account_name="testaccount", - account_key="testkey" + mock_blob_service_client, account_name="testaccount", account_key="testkey" ) - result = await adapter.delete_object( - Bucket="test-container", - Key="test-file.txt" - ) + result = await adapter.delete_object(Bucket="test-container", Key="test-file.txt") # Verify result assert result["ResponseMetadata"]["HTTPStatusCode"] == 204 @@ -242,15 +233,11 @@ async def test_list_objects_v2(mock_blob_service_client): from chuk_artifacts.providers.azure_blob import AzureBlobAdapter adapter = AzureBlobAdapter( - mock_blob_service_client, - account_name="testaccount", - account_key="testkey" + mock_blob_service_client, account_name="testaccount", account_key="testkey" ) result = await adapter.list_objects_v2( - Bucket="test-container", - Prefix="test/", - MaxKeys=1000 + Bucket="test-container", Prefix="test/", MaxKeys=1000 ) # Verify result @@ -267,15 +254,10 @@ async def test_head_object(mock_blob_service_client): from chuk_artifacts.providers.azure_blob import AzureBlobAdapter adapter = AzureBlobAdapter( - mock_blob_service_client, - account_name="testaccount", - account_key="testkey" + mock_blob_service_client, account_name="testaccount", account_key="testkey" ) - result = await adapter.head_object( - Bucket="test-container", - Key="test-file.txt" - ) + result = await adapter.head_object(Bucket="test-container", Key="test-file.txt") # Verify result assert result["ContentType"] == "text/plain" @@ -292,15 +274,13 @@ async def test_generate_presigned_url(mock_blob_service_client): mock_sas.return_value = "sig=abc123&st=2024-01-01&se=2024-01-02" adapter = AzureBlobAdapter( - mock_blob_service_client, - account_name="testaccount", - account_key="testkey" + mock_blob_service_client, account_name="testaccount", account_key="testkey" ) url = await adapter.generate_presigned_url( operation="get_object", Params={"Bucket": "test-container", "Key": "test-file.txt"}, - ExpiresIn=3600 + ExpiresIn=3600, ) # Verify URL structure @@ -316,16 +296,14 @@ async def test_generate_presigned_url_without_credentials(mock_blob_service_clie from chuk_artifacts.providers.azure_blob import AzureBlobAdapter adapter = AzureBlobAdapter( - mock_blob_service_client, - account_name=None, - account_key=None + mock_blob_service_client, account_name=None, account_key=None ) with pytest.raises(RuntimeError, match="Presigned URL generation requires"): await adapter.generate_presigned_url( operation="get_object", Params={"Bucket": "test-container", "Key": "test-file.txt"}, - ExpiresIn=3600 + ExpiresIn=3600, ) @@ -335,9 +313,7 @@ async def test_close(mock_blob_service_client): from chuk_artifacts.providers.azure_blob import AzureBlobAdapter adapter = AzureBlobAdapter( - mock_blob_service_client, - account_name="testaccount", - account_key="testkey" + mock_blob_service_client, account_name="testaccount", account_key="testkey" ) await adapter.close() @@ -352,9 +328,7 @@ async def test_operations_fail_after_close(mock_blob_service_client): from chuk_artifacts.providers.azure_blob import AzureBlobAdapter adapter = AzureBlobAdapter( - mock_blob_service_client, - account_name="testaccount", - account_key="testkey" + mock_blob_service_client, account_name="testaccount", account_key="testkey" ) await adapter.close() @@ -365,5 +339,5 @@ async def test_operations_fail_after_close(mock_blob_service_client): Key="test", Body=b"data", ContentType="text/plain", - Metadata={} + Metadata={}, ) From ae980311bac772274e6baa76911bf0455ce7553b Mon Sep 17 00:00:00 2001 From: Chris PC Date: Fri, 3 Jul 2026 17:07:10 +0300 Subject: [PATCH 06/11] Fix async calls in mock unit tests Signed-off-by: Chris PC --- tests/providers/test_azure_blob_provider.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/providers/test_azure_blob_provider.py b/tests/providers/test_azure_blob_provider.py index 08112d7..80e2725 100644 --- a/tests/providers/test_azure_blob_provider.py +++ b/tests/providers/test_azure_blob_provider.py @@ -37,9 +37,11 @@ def mock_blob_client(mock_blob_properties): @pytest.fixture def mock_container_client(mock_blob_client): """Mock Azure ContainerClient.""" - mock_client = AsyncMock() + mock_client = MagicMock() mock_client.create_container = AsyncMock() - mock_client.get_blob_client = AsyncMock(return_value=mock_blob_client) + mock_client.get_blob_client = MagicMock( + return_value=mock_blob_client + ) # Sync method mock_client.get_container_properties = AsyncMock() # Mock list_blobs to return async iterator @@ -60,8 +62,10 @@ async def async_iter(): @pytest.fixture def mock_blob_service_client(mock_container_client): """Mock Azure BlobServiceClient.""" - mock_client = AsyncMock() - mock_client.get_container_client = AsyncMock(return_value=mock_container_client) + mock_client = MagicMock() + mock_client.get_container_client = MagicMock( + return_value=mock_container_client + ) # Sync method mock_client.close = AsyncMock() return mock_client From 7d6605aecb71aeb5d8ac0020e57c8e6f0201f510 Mon Sep 17 00:00:00 2001 From: Chris PC Date: Mon, 6 Jul 2026 17:13:30 +0300 Subject: [PATCH 07/11] Add Azure AD auth support Signed-off-by: Chris PC --- src/chuk_artifacts/config.py | 128 +++++++++++++- src/chuk_artifacts/provider_factory.py | 6 +- src/chuk_artifacts/providers/azure_blob.py | 193 ++++++++++++++------- src/chuk_artifacts/types.py | 2 + 4 files changed, 268 insertions(+), 61 deletions(-) diff --git a/src/chuk_artifacts/config.py b/src/chuk_artifacts/config.py index 6bbdde1..5496863 100644 --- a/src/chuk_artifacts/config.py +++ b/src/chuk_artifacts/config.py @@ -204,6 +204,128 @@ def configure_ibm_cos( return env_vars +def configure_azure_blob( + *, + account_name: str, + account_key: str, + bucket: str = "artifacts", + session_provider: str = "memory", +) -> Dict[str, str]: + """ + Configure for Azure Blob Storage with account key authentication. + + Parameters + ---------- + account_name : str + Azure storage account name + account_key : str + Azure storage account key + bucket : str, default "artifacts" + Container name + session_provider : str + Session provider (memory or redis) + + Returns + ------- + dict + Environment variables that were set + """ + # Normalize session_provider to enum value if needed + session_provider_value = ( + session_provider + if isinstance(session_provider, str) + else session_provider.value + if isinstance(session_provider, SessionProvider) + else SessionProvider.MEMORY.value + ) + + from .types import StorageProvider + + env_vars = { + "ARTIFACT_PROVIDER": StorageProvider.AZURE_BLOB.value, + "SESSION_PROVIDER": session_provider_value, + "AZURE_STORAGE_ACCOUNT_NAME": account_name, + "AZURE_STORAGE_ACCOUNT_KEY": account_key, + "ARTIFACT_BUCKET": bucket, + } + + for key, value in env_vars.items(): + os.environ[key] = value + + return env_vars + + +def configure_azure_blob_ad( + *, + account_name: str, + client_id: Optional[str] = None, + client_secret: Optional[str] = None, + tenant_id: Optional[str] = None, + bucket: str = "artifacts", + session_provider: str = "memory", +) -> Dict[str, str]: + """ + Configure Azure Blob Storage with Azure AD authentication. + + Parameters + ---------- + account_name : str + Azure storage account name + client_id : str, optional + Azure service principal client ID (if using service principal) + client_secret : str, optional + Azure service principal client secret (if using service principal) + tenant_id : str, optional + Azure tenant ID (if using service principal) + bucket : str, default "artifacts" + Container name + session_provider : str + Session provider (memory or redis) + + Returns + ------- + dict + Environment variables that were set + + Notes + ----- + If client credentials not provided, will use DefaultAzureCredential chain: + - Managed Identity (if on Azure VM/AKS/Functions) + - Azure CLI (if `az login` has been run) + - Environment variables (if AZURE_CLIENT_ID, etc. are set) + """ + # Normalize session_provider to enum value if needed + session_provider_value = ( + session_provider + if isinstance(session_provider, str) + else session_provider.value + if isinstance(session_provider, SessionProvider) + else SessionProvider.MEMORY.value + ) + + from .types import StorageProvider + + env_vars = { + "ARTIFACT_PROVIDER": StorageProvider.AZURE_BLOB.value, + "SESSION_PROVIDER": session_provider_value, + "AZURE_STORAGE_ACCOUNT_NAME": account_name, + "AZURE_USE_AD": "true", + "ARTIFACT_BUCKET": bucket, + } + + if client_id: + env_vars["AZURE_CLIENT_ID"] = client_id + if client_secret: + env_vars["AZURE_CLIENT_SECRET"] = client_secret + if tenant_id: + env_vars["AZURE_TENANT_ID"] = tenant_id + + for key, value in env_vars.items(): + os.environ[key] = value + + return env_vars + + def create_store() -> ArtifactStore: """ Create a new ArtifactStore instance with current environment configuration. @@ -236,7 +358,7 @@ def production_setup(*, storage_type: str, **kwargs) -> ArtifactStore: Parameters ---------- storage_type : str - Storage type: 's3', 'ibm_cos' + Storage type: 's3', 'ibm_cos', 'azure_blob', 'azure_blob_ad' **kwargs Configuration parameters for the chosen storage type @@ -249,6 +371,10 @@ def production_setup(*, storage_type: str, **kwargs) -> ArtifactStore: configure_s3(**kwargs) elif storage_type == "ibm_cos": configure_ibm_cos(**kwargs) + elif storage_type == "azure_blob": + configure_azure_blob(**kwargs) + elif storage_type == "azure_blob_ad": + configure_azure_blob_ad(**kwargs) else: raise ValueError(f"Unknown storage type: {storage_type}") diff --git a/src/chuk_artifacts/provider_factory.py b/src/chuk_artifacts/provider_factory.py index 713599b..0c378d6 100644 --- a/src/chuk_artifacts/provider_factory.py +++ b/src/chuk_artifacts/provider_factory.py @@ -9,7 +9,7 @@ • **fs**, **filesystem** - local filesystem rooted at `$ARTIFACT_FS_ROOT` • **s3** - plain AWS or any S3-compatible endpoint • **ibm_cos** - IBM COS, HMAC credentials (Signature V2) -• **azure_blob** - Azure Blob Storage with connection string or account credentials +• **azure_blob** - Azure Blob Storage (connection string, account key, or Azure AD) Any other value is resolved dynamically as `chuk_artifacts.providers..factory()`. @@ -86,7 +86,9 @@ def factory_for_env() -> Callable[[], AsyncContextManager]: if provider == "azure_blob": from .providers import azure_blob - return azure_blob.factory() + # Pass use_azure_ad explicitly so factory_for_env is the single source of truth + use_azure_ad = os.getenv("AZURE_USE_AD", "").lower() == "true" + return azure_blob.factory(use_azure_ad=use_azure_ad) # --------------------------------------------------------------------------- # Fallback: dynamic lookup – allows user-supplied provider implementations. diff --git a/src/chuk_artifacts/providers/azure_blob.py b/src/chuk_artifacts/providers/azure_blob.py index 450b616..e44894b 100644 --- a/src/chuk_artifacts/providers/azure_blob.py +++ b/src/chuk_artifacts/providers/azure_blob.py @@ -4,7 +4,7 @@ Azure Blob Storage provider for artifact storage. Uses azure-storage-blob to provide Azure Blob Storage with S3-compatible interface. -Supports connection string or account name/key authentication. +Supports connection string, account name/key, or Azure AD authentication. """ from __future__ import annotations @@ -33,6 +33,7 @@ def __init__( blob_service_client, account_name: Optional[str] = None, account_key: Optional[str] = None, + use_azure_ad: bool = False, ): """ Initialize adapter with Azure BlobServiceClient. @@ -45,10 +46,13 @@ def __init__( Storage account name (for SAS token generation) account_key : str, optional Storage account key (for SAS token generation) + use_azure_ad : bool, default False + Whether Azure AD authentication is being used """ self._client = blob_service_client self._account_name = account_name self._account_key = account_key + self._use_azure_ad = use_azure_ad self._closed = False self._lock = asyncio.Lock() @@ -286,19 +290,13 @@ async def generate_presigned_url( ExpiresIn: int, # noqa: N803 ) -> str: """ - Generate presigned URL using Azure SAS token. + Generate presigned URL using SAS token. - Note: Requires account_name and account_key to be set. + Uses User Delegation SAS (Azure AD) or Account Key SAS based on configuration. """ if self._closed: raise RuntimeError("Client has been closed") - if not (self._account_name and self._account_key): - raise RuntimeError( - "Presigned URL generation requires AZURE_STORAGE_ACCOUNT_NAME " - "and AZURE_STORAGE_ACCOUNT_KEY to be set" - ) - from azure.storage.blob import generate_blob_sas, BlobSasPermissions bucket = Params["Bucket"] @@ -312,15 +310,51 @@ async def generate_presigned_url( else: raise ValueError(f"Unsupported operation: {operation}") - # Generate SAS token - sas_token = generate_blob_sas( - account_name=self._account_name, - container_name=bucket, - blob_name=key, - account_key=self._account_key, - permission=permission, - expiry=datetime.now(timezone.utc) + timedelta(seconds=ExpiresIn), - ) + expiry = datetime.now(timezone.utc) + timedelta(seconds=ExpiresIn) + + # Azure AD: User Delegation SAS + if self._use_azure_ad: + if not self._account_name: + raise RuntimeError( + "User Delegation SAS requires AZURE_STORAGE_ACCOUNT_NAME to be set" + ) + + # Get user delegation key from Azure AD + key_start = datetime.now(timezone.utc) + key_expiry = key_start + timedelta(hours=1) + + user_delegation_key = await self._client.get_user_delegation_key( + key_start_time=key_start, + key_expiry_time=key_expiry + ) + + # Generate SAS with delegation key + sas_token = generate_blob_sas( + account_name=self._account_name, + container_name=bucket, + blob_name=key, + user_delegation_key=user_delegation_key, # Azure AD key + permission=permission, + expiry=expiry, + ) + + # Account Key: Standard SAS + else: + if not (self._account_name and self._account_key): + raise RuntimeError( + "Presigned URL generation requires AZURE_STORAGE_ACCOUNT_NAME " + "and AZURE_STORAGE_ACCOUNT_KEY, or Azure AD authentication" + ) + + # Generate SAS with account key + sas_token = generate_blob_sas( + account_name=self._account_name, + container_name=bucket, + blob_name=key, + account_key=self._account_key, # Static key + permission=permission, + expiry=expiry, + ) # Construct full URL blob_url = f"https://{self._account_name}.blob.core.windows.net/{bucket}/{key}" @@ -338,6 +372,7 @@ def factory( connection_string: Optional[str] = None, account_name: Optional[str] = None, account_key: Optional[str] = None, + use_azure_ad: bool = False, ) -> Callable[[], AsyncContextManager]: """ Create Azure Blob Storage client factory. @@ -345,11 +380,14 @@ def factory( Parameters ---------- connection_string : str, optional - Azure storage connection string (preferred method) + Azure storage connection string account_name : str, optional - Storage account name (alternative to connection_string) + Storage account name account_key : str, optional - Storage account key (alternative to connection_string) + Storage account key + use_azure_ad : bool, default False + Use Azure AD authentication instead of account key. + Requires Azure AD credentials configured (env vars, managed identity, or az cli). Returns ------- @@ -358,58 +396,97 @@ def factory( Environment Variables --------------------- - - AZURE_STORAGE_CONNECTION_STRING: Full connection string - - AZURE_STORAGE_ACCOUNT_NAME: Storage account name - - AZURE_STORAGE_ACCOUNT_KEY: Storage account key + Account Key Auth: + - AZURE_STORAGE_CONNECTION_STRING, or + - AZURE_STORAGE_ACCOUNT_NAME + AZURE_STORAGE_ACCOUNT_KEY + + Azure AD Auth: + - AZURE_STORAGE_ACCOUNT_NAME (required) + - AZURE_USE_AD=true (to enable) + - Service Principal: AZURE_CLIENT_ID, AZURE_CLIENT_SECRET, AZURE_TENANT_ID + - Or use Managed Identity (automatic on Azure VMs/AKS/Functions) + - Or use Azure CLI: az login """ + # Check for Azure AD preference from environment + use_azure_ad = use_azure_ad or os.getenv("AZURE_USE_AD", "").lower() == "true" + # Get configuration from parameters or environment - connection_string = connection_string or os.getenv( - "AZURE_STORAGE_CONNECTION_STRING" - ) + connection_string = connection_string or os.getenv("AZURE_STORAGE_CONNECTION_STRING") account_name = account_name or os.getenv("AZURE_STORAGE_ACCOUNT_NAME") account_key = account_key or os.getenv("AZURE_STORAGE_ACCOUNT_KEY") - # Validate credentials - if not connection_string and not (account_name and account_key): - raise RuntimeError( - "Azure credentials missing. Set AZURE_STORAGE_CONNECTION_STRING " - "or AZURE_STORAGE_ACCOUNT_NAME + AZURE_STORAGE_ACCOUNT_KEY " - "environment variables." - ) + # Validate credentials based on auth method + if use_azure_ad: + # Azure AD only needs account name + if not account_name: + raise RuntimeError( + "Azure AD authentication requires AZURE_STORAGE_ACCOUNT_NAME to be set" + ) + else: + # Account key auth needs connection string OR (name + key) + if not connection_string and not (account_name and account_key): + raise RuntimeError( + "Azure credentials missing. Set AZURE_STORAGE_CONNECTION_STRING " + "or AZURE_STORAGE_ACCOUNT_NAME + AZURE_STORAGE_ACCOUNT_KEY, " + "or set AZURE_USE_AD=true for Azure AD authentication." + ) @asynccontextmanager async def _ctx(): from azure.storage.blob.aio import BlobServiceClient - # Create client based on credential type - if connection_string: - client = BlobServiceClient.from_connection_string(connection_string) - # Extract account name from connection string if needed - if not account_name: + account_url = f"https://{account_name}.blob.core.windows.net" + + # Azure AD authentication + if use_azure_ad: + from azure.identity.aio import DefaultAzureCredential + + credential = DefaultAzureCredential() + client = BlobServiceClient(account_url=account_url, credential=credential) + + adapter = AzureBlobAdapter( + client, + account_name=account_name, + account_key=None, + use_azure_ad=True + ) + + try: + yield adapter + finally: try: - for part in connection_string.split(";"): - if part.startswith("AccountName="): - extracted_name = part.split("=", 1)[1] - break - else: + await adapter.close() + finally: + await credential.close() + + # Account key authentication (existing logic) + else: + if connection_string: + client = BlobServiceClient.from_connection_string(connection_string) + # Extract account name from connection string if needed + extracted_name = account_name + if not extracted_name: + try: + for part in connection_string.split(";"): + if part.startswith("AccountName="): + extracted_name = part.split("=", 1)[1] + break + except Exception: extracted_name = None - except Exception: - extracted_name = None else: + client = BlobServiceClient(account_url=account_url, credential=account_key) extracted_name = account_name - else: - account_url = f"https://{account_name}.blob.core.windows.net" - client = BlobServiceClient(account_url=account_url, credential=account_key) - extracted_name = account_name - # Wrap in adapter - adapter = AzureBlobAdapter( - client, account_name=extracted_name, account_key=account_key - ) + adapter = AzureBlobAdapter( + client, + account_name=extracted_name, + account_key=account_key, + use_azure_ad=False + ) - try: - yield adapter - finally: - await adapter.close() + try: + yield adapter + finally: + await adapter.close() return _ctx diff --git a/src/chuk_artifacts/types.py b/src/chuk_artifacts/types.py index a173078..69b6f05 100644 --- a/src/chuk_artifacts/types.py +++ b/src/chuk_artifacts/types.py @@ -36,6 +36,7 @@ class StorageProvider(str, Enum): - FILESYSTEM: Local filesystem storage - S3: AWS S3 or S3-compatible storage - IBM_COS: IBM Cloud Object Storage + - AZURE_BLOB: Azure Blob Storage VFS providers (recommended for both artifacts and workspaces): - VFS_MEMORY: VFS-based memory storage @@ -48,6 +49,7 @@ class StorageProvider(str, Enum): FILESYSTEM = "filesystem" S3 = "s3" IBM_COS = "ibm_cos" + AZURE_BLOB = "azure_blob" VFS = "vfs" VFS_MEMORY = "vfs-memory" VFS_FILESYSTEM = "vfs-filesystem" From 293e8150d37881492f148f26730f25291aa88b25 Mon Sep 17 00:00:00 2001 From: Chris PC Date: Mon, 6 Jul 2026 17:14:04 +0300 Subject: [PATCH 08/11] Update documentation, dependencies and tests Signed-off-by: Chris PC --- README.md | 25 +++- examples/11_azure_blob_quickstart.py | 106 ++++++++++--- pyproject.toml | 1 + tests/providers/test_azure_blob_provider.py | 157 +++++++++++++++++++- 4 files changed, 266 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 00cf301..4f1c71f 100644 --- a/README.md +++ b/README.md @@ -730,10 +730,24 @@ export AWS_DEFAULT_REGION=us-east-1 # Azure Blob Storage (persistent cloud storage) export ARTIFACT_PROVIDER=azure_blob + +# Option 1: Connection String export AZURE_STORAGE_CONNECTION_STRING="DefaultEndpointsProtocol=https;AccountName=...;AccountKey=...;EndpointSuffix=core.windows.net" -# OR use account credentials: + +# Option 2: Account Name + Key export AZURE_STORAGE_ACCOUNT_NAME=your_account export AZURE_STORAGE_ACCOUNT_KEY=your_key + +# Option 3: Azure AD (Recommended for production) +export AZURE_STORAGE_ACCOUNT_NAME=your_account +export AZURE_USE_AD=true +# For service principal (for apps/CICD): +export AZURE_CLIENT_ID=your-client-id +export AZURE_CLIENT_SECRET=your-client-secret +export AZURE_TENANT_ID=your-tenant-id +# Or use Managed Identity (if on Azure VM/AKS/Functions) +# Or use Azure CLI: az login (for local dev) + export ARTIFACT_BUCKET=your-container ``` @@ -813,9 +827,14 @@ CHUK Artifacts is designed for high performance: **Azure Blob Storage Provider:** - Native async Azure SDK -- Supports connection string or account credentials +- Multiple authentication methods: + - Account key (connection string or name+key) + - Azure AD (Service Principal, Managed Identity, Azure CLI) - Compatible with all Azure storage tiers -- Presigned URL support via SAS tokens +- Presigned URL support: + - Account Key SAS (requires "Allow Blob public access" setting) + - User Delegation SAS with Azure AD (works without public access setting) +- Production-ready with RBAC and identity-based access **SQLite Provider:** - Fast for small to medium workspaces diff --git a/examples/11_azure_blob_quickstart.py b/examples/11_azure_blob_quickstart.py index c871868..aa02a53 100644 --- a/examples/11_azure_blob_quickstart.py +++ b/examples/11_azure_blob_quickstart.py @@ -15,19 +15,14 @@ async def azure_blob_example(): from chuk_artifacts import ArtifactStore # Configure Azure Blob Storage + # Credentials should be set in your environment before running this script: # Option 1: Connection String (recommended) - os.environ["ARTIFACT_PROVIDER"] = "azure_blob" - os.environ["AZURE_STORAGE_CONNECTION_STRING"] = ( - "DefaultEndpointsProtocol=https;" - "AccountName=your_account;" - "AccountKey=your_key_here;" - "EndpointSuffix=core.windows.net" - ) - - # OR Option 2: Account Name + Key - # os.environ["AZURE_STORAGE_ACCOUNT_NAME"] = "your_account" - # os.environ["AZURE_STORAGE_ACCOUNT_KEY"] = "your_key_here" + # export AZURE_STORAGE_CONNECTION_STRING="DefaultEndpointsProtocol=https;AccountName=...;AccountKey=...;EndpointSuffix=core.windows.net" + # Option 2: Account Name + Key + # export AZURE_STORAGE_ACCOUNT_NAME="your_account" + # export AZURE_STORAGE_ACCOUNT_KEY="your_key" + os.environ["ARTIFACT_PROVIDER"] = "azure_blob" os.environ["ARTIFACT_BUCKET"] = "my-artifacts-container" os.environ["SESSION_PROVIDER"] = "memory" @@ -79,35 +74,108 @@ async def azure_with_programmatic_config(): print("✓ Created store with programmatic config") +async def azure_with_azure_ad(): + """Example using Azure AD authentication.""" + from chuk_artifacts import ArtifactStore + + # Azure AD configuration (no account key needed!) + os.environ["ARTIFACT_PROVIDER"] = "azure_blob" + os.environ["AZURE_STORAGE_ACCOUNT_NAME"] = "your_account" + os.environ["AZURE_USE_AD"] = "true" + + # Option 1: Service Principal (for applications) + # os.environ["AZURE_CLIENT_ID"] = "your-client-id" + # os.environ["AZURE_CLIENT_SECRET"] = "your-client-secret" + # os.environ["AZURE_TENANT_ID"] = "your-tenant-id" + + # Option 2: Managed Identity (if running on Azure VM/AKS/Functions) + # No credentials needed - identity is automatic! + + # Option 3: Azure CLI (for local development) + # Just run: az login + + os.environ["ARTIFACT_BUCKET"] = "my-artifacts-container" + os.environ["SESSION_PROVIDER"] = "memory" + + store = ArtifactStore( + storage_provider="azure_blob", + session_provider="memory", + bucket="my-artifacts-container", + ) + + print("✓ Connected to Azure Blob Storage with Azure AD") + + # Store an artifact + artifact_id = await store.store( + data=b"Hello from Azure AD!", + mime="text/plain", + summary="Test artifact", + session_id="demo-session", + ) + print(f"✓ Stored artifact: {artifact_id}") + + # Generate presigned URL (User Delegation SAS) + # Works WITHOUT "Allow Blob public access" enabled! + presigned = await store.presign(artifact_id, expires=3600) + print(f"✓ Presigned URL generated: {presigned[:80]}...") + print(" (This works without 'Allow Blob public access' setting!)") + + # Cleanup + await store.delete(artifact_id) + print("✓ Deleted artifact") + + print("\n✅ Azure AD authentication example complete!") + + async def main(): """Run examples.""" print("Azure Blob Storage Examples") print("=" * 50) - # Check if credentials are set - if not ( + # Check if Azure AD is configured + azure_ad_configured = os.getenv("AZURE_USE_AD", "").lower() == "true" + + # Check if account key credentials are set + account_key_configured = bool( os.getenv("AZURE_STORAGE_CONNECTION_STRING") or ( os.getenv("AZURE_STORAGE_ACCOUNT_NAME") and os.getenv("AZURE_STORAGE_ACCOUNT_KEY") ) - ): + ) + + if not (azure_ad_configured or account_key_configured): print("\n⚠️ Azure credentials not configured") - print("Set AZURE_STORAGE_CONNECTION_STRING or") - print("AZURE_STORAGE_ACCOUNT_NAME + AZURE_STORAGE_ACCOUNT_KEY") + print("\nOption 1: Account Key Authentication") + print(" Set AZURE_STORAGE_CONNECTION_STRING or") + print(" AZURE_STORAGE_ACCOUNT_NAME + AZURE_STORAGE_ACCOUNT_KEY") + print("\nOption 2: Azure AD Authentication") + print(" Set AZURE_STORAGE_ACCOUNT_NAME") + print(" Set AZURE_USE_AD=true") + print(" Plus: AZURE_CLIENT_ID, AZURE_CLIENT_SECRET, AZURE_TENANT_ID") + print(" Or: az login (for Azure CLI authentication)") print("\nTo run this example:") print(' export AZURE_STORAGE_CONNECTION_STRING="..."') - print(" python examples/azure_blob_quickstart.py") + print(" python examples/11_azure_blob_quickstart.py") return try: - await azure_blob_example() + if azure_ad_configured: + print("\n🔐 Using Azure AD Authentication\n") + await azure_with_azure_ad() + else: + print("\n🔑 Using Account Key Authentication\n") + await azure_blob_example() except Exception as e: print(f"\n❌ Error: {e}") print("\nMake sure:") print("1. Azure credentials are correct") print("2. Container exists or you have permission to create it") - print("3. azure-storage-blob package is installed") + print("3. azure-storage-blob and azure-identity packages are installed") + if azure_ad_configured: + print("4. Azure AD identity has proper RBAC roles:") + print(" - Storage Blob Data Contributor") + print(" - Storage Blob Delegator (for presigned URLs)") if __name__ == "__main__": diff --git a/pyproject.toml b/pyproject.toml index 1f34cf1..06f4383 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,7 @@ dependencies = [ "dotenv>=0.9.9", "asyncio>=3.4.3", "azure-storage-blob>=12.19.0", + "azure-identity>=1.15.0", ] [project.optional-dependencies] diff --git a/tests/providers/test_azure_blob_provider.py b/tests/providers/test_azure_blob_provider.py index 80e2725..7d1a7ed 100644 --- a/tests/providers/test_azure_blob_provider.py +++ b/tests/providers/test_azure_blob_provider.py @@ -7,7 +7,7 @@ import pytest from unittest.mock import AsyncMock, MagicMock, patch -from datetime import datetime, timezone +from datetime import datetime, timezone, timedelta @pytest.fixture @@ -345,3 +345,158 @@ async def test_operations_fail_after_close(mock_blob_service_client): ContentType="text/plain", Metadata={}, ) + + +# Azure AD Authentication Tests + + +@pytest.fixture +def mock_azure_credential(): + """Mock Azure AD DefaultAzureCredential.""" + mock_cred = AsyncMock() + mock_cred.close = AsyncMock() + return mock_cred + + +@pytest.fixture +def mock_user_delegation_key(): + """Mock user delegation key from Azure AD.""" + mock_key = MagicMock() + mock_key.signed_oid = "user-oid-123" + mock_key.signed_tid = "tenant-id-456" + mock_key.signed_start = datetime.now(timezone.utc) + mock_key.signed_expiry = datetime.now(timezone.utc) + timedelta(hours=1) + mock_key.signed_service = "b" # blob service + mock_key.signed_version = "2021-08-06" + mock_key.value = "delegation-key-value" + return mock_key + + +@pytest.fixture +def mock_blob_service_client_with_ad(mock_container_client, mock_user_delegation_key): + """Mock Azure BlobServiceClient with Azure AD support.""" + mock_client = MagicMock() + mock_client.get_container_client = MagicMock(return_value=mock_container_client) + mock_client.get_user_delegation_key = AsyncMock(return_value=mock_user_delegation_key) + mock_client.close = AsyncMock() + return mock_client + + +@pytest.mark.asyncio +async def test_factory_with_azure_ad(): + """Test factory initialization with Azure AD.""" + with patch.dict( + "os.environ", + { + "AZURE_STORAGE_ACCOUNT_NAME": "testaccount", + "AZURE_USE_AD": "true", + }, + ): + with patch("azure.identity.aio.DefaultAzureCredential") as mock_cred_class: + with patch("azure.storage.blob.aio.BlobServiceClient") as mock_client_class: + mock_cred = AsyncMock() + mock_cred.close = AsyncMock() + mock_cred_class.return_value = mock_cred + + mock_client = AsyncMock() + mock_client.close = AsyncMock() + mock_client_class.return_value = mock_client + + # Import after patching + import sys + + if "chuk_artifacts.providers.azure_blob" in sys.modules: + del sys.modules["chuk_artifacts.providers.azure_blob"] + + from chuk_artifacts.providers.azure_blob import factory + + # Create factory + client_factory = factory() + + # Use context manager + async with client_factory() as client: + assert client is not None + assert client._use_azure_ad is True + assert not client._closed + + # Verify credential was created and closed + mock_cred_class.assert_called_once() + mock_cred.close.assert_called_once() + + +@pytest.mark.asyncio +async def test_factory_azure_ad_missing_account_name(): + """Test factory fails with Azure AD but missing account name.""" + with patch.dict("os.environ", {"AZURE_USE_AD": "true"}, clear=True): + # Import after patching + import sys + + if "chuk_artifacts.providers.azure_blob" in sys.modules: + del sys.modules["chuk_artifacts.providers.azure_blob"] + + from chuk_artifacts.providers.azure_blob import factory + + with pytest.raises(RuntimeError, match="Azure AD authentication requires"): + factory() + + +@pytest.mark.asyncio +async def test_generate_presigned_url_with_azure_ad( + mock_blob_service_client_with_ad, mock_user_delegation_key +): + """Test generating presigned URL with Azure AD.""" + from chuk_artifacts.providers.azure_blob import AzureBlobAdapter + + adapter = AzureBlobAdapter( + mock_blob_service_client_with_ad, + account_name="testaccount", + account_key=None, + use_azure_ad=True, + ) + + with patch("azure.storage.blob.generate_blob_sas") as mock_sas: + mock_sas.return_value = "sig=abc123&st=2024-01-01&se=2024-01-02" + + url = await adapter.generate_presigned_url( + operation="get_object", + Params={"Bucket": "test-container", "Key": "test-file.txt"}, + ExpiresIn=3600, + ) + + # Verify URL structure + assert "testaccount.blob.core.windows.net" in url + assert "test-container" in url + assert "test-file.txt" in url + assert "sig=" in url + + # Verify get_user_delegation_key was called + mock_blob_service_client_with_ad.get_user_delegation_key.assert_called_once() + + # Verify generate_blob_sas called with user_delegation_key + assert mock_sas.called + call_kwargs = mock_sas.call_args.kwargs + assert "user_delegation_key" in call_kwargs + assert call_kwargs["user_delegation_key"] == mock_user_delegation_key + assert "account_key" not in call_kwargs + + +@pytest.mark.asyncio +async def test_azure_ad_presigned_url_without_account_name( + mock_blob_service_client_with_ad, +): + """Test Azure AD presigned URL fails without account name.""" + from chuk_artifacts.providers.azure_blob import AzureBlobAdapter + + adapter = AzureBlobAdapter( + mock_blob_service_client_with_ad, + account_name=None, + account_key=None, + use_azure_ad=True, + ) + + with pytest.raises(RuntimeError, match="User Delegation SAS requires"): + await adapter.generate_presigned_url( + operation="get_object", + Params={"Bucket": "test-container", "Key": "test-file.txt"}, + ExpiresIn=3600, + ) From fb99763e6b093ea363e6951bf3d9f3085c44c228 Mon Sep 17 00:00:00 2001 From: Chris PC Date: Mon, 6 Jul 2026 17:15:11 +0300 Subject: [PATCH 09/11] Reformat with ruffs Signed-off-by: Chris PC --- src/chuk_artifacts/providers/azure_blob.py | 18 +++---- tests/providers/test_azure_blob_provider.py | 4 +- uv.lock | 58 +++++++++++++++++++++ 3 files changed, 70 insertions(+), 10 deletions(-) diff --git a/src/chuk_artifacts/providers/azure_blob.py b/src/chuk_artifacts/providers/azure_blob.py index e44894b..cf5dd94 100644 --- a/src/chuk_artifacts/providers/azure_blob.py +++ b/src/chuk_artifacts/providers/azure_blob.py @@ -324,8 +324,7 @@ async def generate_presigned_url( key_expiry = key_start + timedelta(hours=1) user_delegation_key = await self._client.get_user_delegation_key( - key_start_time=key_start, - key_expiry_time=key_expiry + key_start_time=key_start, key_expiry_time=key_expiry ) # Generate SAS with delegation key @@ -411,7 +410,9 @@ def factory( use_azure_ad = use_azure_ad or os.getenv("AZURE_USE_AD", "").lower() == "true" # Get configuration from parameters or environment - connection_string = connection_string or os.getenv("AZURE_STORAGE_CONNECTION_STRING") + connection_string = connection_string or os.getenv( + "AZURE_STORAGE_CONNECTION_STRING" + ) account_name = account_name or os.getenv("AZURE_STORAGE_ACCOUNT_NAME") account_key = account_key or os.getenv("AZURE_STORAGE_ACCOUNT_KEY") @@ -445,10 +446,7 @@ async def _ctx(): client = BlobServiceClient(account_url=account_url, credential=credential) adapter = AzureBlobAdapter( - client, - account_name=account_name, - account_key=None, - use_azure_ad=True + client, account_name=account_name, account_key=None, use_azure_ad=True ) try: @@ -474,14 +472,16 @@ async def _ctx(): except Exception: extracted_name = None else: - client = BlobServiceClient(account_url=account_url, credential=account_key) + client = BlobServiceClient( + account_url=account_url, credential=account_key + ) extracted_name = account_name adapter = AzureBlobAdapter( client, account_name=extracted_name, account_key=account_key, - use_azure_ad=False + use_azure_ad=False, ) try: diff --git a/tests/providers/test_azure_blob_provider.py b/tests/providers/test_azure_blob_provider.py index 7d1a7ed..c6087a1 100644 --- a/tests/providers/test_azure_blob_provider.py +++ b/tests/providers/test_azure_blob_provider.py @@ -377,7 +377,9 @@ def mock_blob_service_client_with_ad(mock_container_client, mock_user_delegation """Mock Azure BlobServiceClient with Azure AD support.""" mock_client = MagicMock() mock_client.get_container_client = MagicMock(return_value=mock_container_client) - mock_client.get_user_delegation_key = AsyncMock(return_value=mock_user_delegation_key) + mock_client.get_user_delegation_key = AsyncMock( + return_value=mock_user_delegation_key + ) mock_client.close = AsyncMock() return mock_client diff --git a/uv.lock b/uv.lock index 6af78e2..48d402e 100644 --- a/uv.lock +++ b/uv.lock @@ -246,6 +246,22 @@ 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-identity" +version = "1.25.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-core" }, + { name = "cryptography" }, + { name = "msal" }, + { name = "msal-extensions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c5/0e/3a63efb48aa4a5ae2cfca61ee152fbcb668092134d3eb8bfda472dd5c617/azure_identity-1.25.3.tar.gz", hash = "sha256:ab23c0d63015f50b630ef6c6cf395e7262f439ce06e5d07a64e874c724f8d9e6", size = 286304, upload-time = "2026-03-13T01:12:20.892Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/9a/417b3a533e01953a7c618884df2cb05a71e7b68bdbce4fbdb62349d2a2e8/azure_identity-1.25.3-py3-none-any.whl", hash = "sha256:f4d0b956a8146f30333e071374171f3cfa7bdb8073adb8c3814b65567aa7447c", size = 192138, upload-time = "2026-03-13T01:12:22.951Z" }, +] + [[package]] name = "azure-storage-blob" version = "12.30.0" @@ -464,6 +480,7 @@ source = { editable = "." } dependencies = [ { name = "aioboto3" }, { name = "asyncio" }, + { name = "azure-identity" }, { name = "azure-storage-blob" }, { name = "chuk-sessions" }, { name = "chuk-virtual-fs" }, @@ -493,6 +510,7 @@ dev = [ requires-dist = [ { name = "aioboto3", specifier = ">=14.3.0" }, { name = "asyncio", specifier = ">=3.4.3" }, + { name = "azure-identity", specifier = ">=1.15.0" }, { name = "azure-storage-blob", specifier = ">=12.19.0" }, { name = "chuk-sessions", specifier = ">=0.6.1" }, { name = "chuk-virtual-fs", specifier = ">=0.5.1" }, @@ -983,6 +1001,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/aa/47/7d70414bcdbb3bc1f458a8d10558f00bbfdb24e5a11740fc8197e12c3255/librt-0.9.0-cp314-cp314t-win_arm64.whl", hash = "sha256:a4b25c6c25cac5d0d9d6d6da855195b254e0021e513e0249f0e3b444dc6e0e61", size = 50009, upload-time = "2026-04-09T16:06:07.995Z" }, ] +[[package]] +name = "msal" +version = "1.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9a/99/d840198ecf6e8057bbc937f129ae940404485d736cda73253bbff9537f01/msal-1.37.0.tar.gz", hash = "sha256:1b1672a33ee467c1d70b341bb16cafd51bb3c817147a95b93263794b03971bec", size = 182444, upload-time = "2026-05-29T19:49:05.561Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/b0/d807279f4b55d16d1f120d5ac4344c6e39b56732e2a224d40bded7fd67ad/msal-1.37.0-py3-none-any.whl", hash = "sha256:dd17e95a7c71bce75e8108113438ba7c4a086b3bcad4f57a8c09b7af3d753c2d", size = 123725, upload-time = "2026-05-29T19:49:04.335Z" }, +] + +[[package]] +name = "msal-extensions" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "msal" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/01/99/5d239b6156eddf761a636bded1118414d161bd6b7b37a9335549ed159396/msal_extensions-1.3.1.tar.gz", hash = "sha256:c5b0fd10f65ef62b5f1d62f4251d51cbcaf003fcedae8c91b040a488614be1a4", size = 23315, upload-time = "2025-03-14T23:51:03.902Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/75/bd9b7bb966668920f06b200e84454c8f3566b102183bc55c5473d96cb2b9/msal_extensions-1.3.1-py3-none-any.whl", hash = "sha256:96d3de4d034504e969ac5e85bae8106c8373b5c6568e4c8fa7af2eca9dbe6bca", size = 20583, upload-time = "2025-03-14T23:51:03.016Z" }, +] + [[package]] name = "multidict" version = "6.7.1" @@ -1420,6 +1464,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] +[[package]] +name = "pyjwt" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + [[package]] name = "pytest" version = "9.0.3" From 3826ea25ec58cd0e78c4233bfe4eb49bfa651191 Mon Sep 17 00:00:00 2001 From: Chris PC Date: Mon, 6 Jul 2026 17:17:21 +0300 Subject: [PATCH 10/11] Add provider tests Signed-off-by: Chris PC --- .../providers/test_azure_blob_integration.py | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 tests/providers/test_azure_blob_integration.py diff --git a/tests/providers/test_azure_blob_integration.py b/tests/providers/test_azure_blob_integration.py new file mode 100644 index 0000000..bbe5b0f --- /dev/null +++ b/tests/providers/test_azure_blob_integration.py @@ -0,0 +1,79 @@ +# -*- coding: utf-8 -*- +""" +Integration tests for Azure Blob Storage with Azure AD authentication. + +These tests require Azure AD credentials to be configured. +Set AZURE_USE_AD=true to run these tests. +""" + +import os +import pytest + + +class TestAzureBlobAzureAD: + """Integration tests for Azure AD authentication.""" + + @pytest.mark.asyncio + async def test_azure_ad_authentication(self, test_container_name): + """Test Azure AD authentication (if configured).""" + # Only run if Azure AD credentials are configured + if not os.getenv("AZURE_USE_AD"): + pytest.skip("Azure AD not configured (set AZURE_USE_AD=true to test)") + + from chuk_artifacts.providers.azure_blob import factory + + # Create factory with Azure AD + client_factory = factory(use_azure_ad=True) + + # Test connection + async with client_factory() as adapter: + assert adapter is not None + assert adapter._use_azure_ad is True + assert not adapter._closed + + # Test basic operations + test_key = "azure-ad-test/test-file.txt" + test_data = b"Azure AD authentication test" + + # Upload + put_result = await adapter.put_object( + Bucket=test_container_name, + Key=test_key, + Body=test_data, + ContentType="text/plain", + Metadata={"auth": "azure_ad"}, + ) + + assert put_result["ResponseMetadata"]["HTTPStatusCode"] == 200 + + # Download + get_result = await adapter.get_object( + Bucket=test_container_name, Key=test_key + ) + + assert get_result["Body"] == test_data + assert get_result["Metadata"]["auth"] == "azure_ad" + + # Presigned URL (User Delegation SAS) + url = await adapter.generate_presigned_url( + operation="get_object", + Params={"Bucket": test_container_name, "Key": test_key}, + ExpiresIn=3600, + ) + + assert adapter._account_name in url + assert test_container_name in url + assert test_key in url + assert "sig=" in url + + # Cleanup + await adapter.delete_object(Bucket=test_container_name, Key=test_key) + + +@pytest.fixture +def test_container_name(): + """Provide a test container name.""" + from datetime import datetime + + timestamp = datetime.now().strftime("%Y%m%d%H%M%S") + return f"chuk-test-{timestamp}" From da47c78ab3f253e1e2a4d54cc7b533938618a7f1 Mon Sep 17 00:00:00 2001 From: Chris PC Date: Tue, 7 Jul 2026 14:30:54 +0300 Subject: [PATCH 11/11] Fix issues from comments Signed-off-by: Chris PC --- README.md | 13 ++----------- src/chuk_artifacts/provider_factory.py | 15 +++------------ 2 files changed, 5 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 4f1c71f..c2aa0aa 100644 --- a/README.md +++ b/README.md @@ -206,16 +206,6 @@ This is fundamentally more powerful than raw storage. pip install chuk-artifacts ``` -**With optional cloud storage providers:** - -```bash -# Azure Blob Storage -pip install chuk-artifacts[azure] - -# All extras together -pip install chuk-artifacts[azure] -``` - **Dependencies:** - `chuk-virtual-fs` - VFS layer (automatically installed) - `chuk-sessions` - Session management (automatically installed) @@ -224,7 +214,7 @@ pip install chuk-artifacts[azure] - `redis` - For Redis session provider - `boto3` - For S3 storage backend (automatically installed) - `ibm-cos-sdk` - For IBM Cloud Object Storage (automatically installed) -- `azure-storage-blob` - For Azure Blob Storage (install with `[azure]` extra) +- `azure-storage-blob` - For Azure Blob Storage (automatically installed) --- @@ -533,6 +523,7 @@ We provide **9 comprehensive examples** covering all features: 7. **[06_session_isolation.py](examples/06_session_isolation.py)** - Session isolation and scoping 8. **[07_large_files_streaming.py](examples/07_large_files_streaming.py)** - Large file handling 9. **[08_batch_operations.py](examples/08_batch_operations.py)** - Batch operations +10. **[11_azure_blob_quickstart.py](examples/11_azure_blob_quickstart.py)** - Azure Blob quickstart Run any example: diff --git a/src/chuk_artifacts/provider_factory.py b/src/chuk_artifacts/provider_factory.py index 0c378d6..05ac239 100644 --- a/src/chuk_artifacts/provider_factory.py +++ b/src/chuk_artifacts/provider_factory.py @@ -97,18 +97,9 @@ def factory_for_env() -> Callable[[], AsyncContextManager]: mod = import_module(f"chuk_artifacts.providers.{provider}") except ModuleNotFoundError as exc: # Provide helpful error message with available providers - available = [ - "memory", - "filesystem", - "s3", - "ibm_cos", - "azure_blob", - "vfs", - "vfs-memory", - "vfs-filesystem", - "vfs-s3", - "vfs-sqlite", - ] + from .types import StorageProvider + + available = [p.value for p in StorageProvider] raise ValueError( f"Unknown storage provider '{provider}'. " f"Available providers: {', '.join(available)}"