diff --git a/README.md b/README.md index b30eced..a3825fa 100644 --- a/README.md +++ b/README.md @@ -100,22 +100,57 @@ except Exception as e: ## API Reference -### UTApi Methods +### Client Classes -All methods are defined in [`upyloadthing/client.py`](upyloadthing/client.py): +Both clients provide the same methods with identical parameters, but different execution patterns: -- `upload_files(files)` - Upload one or more files -- `delete_files(keys, key_type='file_key')` - Delete files by key or custom ID -- `list_files(limit=None, offset=None)` - List uploaded files -- `get_usage_info()` - Get account usage statistics +#### UTApi (Synchronous) +```python +from upyloadthing import UTApi + +api = UTApi(UTApiOptions(token="your-token")) +result = api.upload_files(file) +``` + +#### AsyncUTApi (Asynchronous) +```python +from upyloadthing import AsyncUTApi + +api = AsyncUTApi(UTApiOptions(token="your-token")) +result = await api.upload_files(file) +``` + +### Methods + +Both clients provide these methods: + +- `upload_files(files: BinaryIO | List[BinaryIO], content_disposition: str = "inline", acl: str | None = "public-read") -> List[UploadResult]` + - Upload one or more files + - Returns list of upload results + +- `delete_files(keys: str | List[str], key_type: str = "file_key") -> DeleteFileResponse` + - Delete one or more files by key or custom ID + - Returns deletion result + +- `list_files(limit: int | None = None, offset: int | None = None) -> ListFileResponse` + - List uploaded files with optional pagination + - Returns file listing + +- `get_usage_info() -> UsageInfoResponse` + - Get account usage statistics + - Returns usage information ### Response Models -All response models are defined in [`upyloadthing/schemas.py`](upyloadthing/schemas.py): +All response models are defined in `upyloadthing/schemas.py`: - `UploadResult` - File upload result containing: - - `ufs_url: str` + - `file_key: str` + - `name: str` + - `size: int` + - `type: str` - `url: str` + - `ufs_url: str` - `app_url: str` - `file_hash: str` - `server_data: Dict | None` diff --git a/examples/async_main.py b/examples/async_main.py new file mode 100644 index 0000000..e484596 --- /dev/null +++ b/examples/async_main.py @@ -0,0 +1,66 @@ +import asyncio +from io import BytesIO +from typing import List + +from upyloadthing.async_client import AsyncUTApi +from upyloadthing.schemas import UploadResult + + +async def main(): + print("🚀 UploadThing API Demo (Async)\n") + + # Initialize the client + api = AsyncUTApi() + + # Get usage info + print("📊 Getting usage info...") + usage_info = await api.get_usage_info() + print(f"Total bytes used: {usage_info.total_bytes}") + print(f"Files uploaded: {usage_info.files_uploaded}") + print(f"Storage limit: {usage_info.limit_bytes}\n") + + # List files + print("📋 Listing files...") + file_list = await api.list_files(limit=5) + print( + f"Fetched {len(file_list.files)} files, has more: {file_list.has_more}" + ) + for file in file_list.files: + print(file) + print() + + # Prepare test files + print("📤 Uploading test images...") + + # Prepare PNG file + with open("./examples/test.png", "rb") as f: + image_content = f.read() + png_file = BytesIO(image_content) + png_file.name = "test.png" + + # Prepare Jpeg file + with open("./examples/test.jpg", "rb") as f: + image_content = f.read() + jpeg_file = BytesIO(image_content) + jpeg_file.name = "test.jpg" + + # Upload both files + upload_results: List[UploadResult] = await api.upload_files( + [png_file, jpeg_file], acl="public-read" + ) + + print("Upload results:") + for result in upload_results: + print(f"- {result.name}: {result.file_key}") + print() + + # Delete the uploaded files + print("🗑️ Deleting test files...") + file_keys = [result.file_key for result in upload_results] + delete_result = await api.delete_files(file_keys) + print(f"Deleted {delete_result.deleted_count} file(s)") + print(f"Success: {delete_result.success}\n") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/main.py b/examples/main.py index 4ae6738..dd440ab 100644 --- a/examples/main.py +++ b/examples/main.py @@ -1,4 +1,5 @@ from io import BytesIO +from typing import List from upyloadthing.client import UTApi from upyloadthing.schemas import UploadResult @@ -27,20 +28,35 @@ def main(): print(file) print() - # Upload an image file - print("📤 Uploading test image...") + # Prepare test files + print("📤 Uploading test images...") + + # Prepare PNG file with open("./examples/test.png", "rb") as f: image_content = f.read() - image_file = BytesIO(image_content) - image_file.name = "test.png" - upload_result: UploadResult = api.upload_files( - image_file, acl="public-read" - ) # type: ignore - print(f"File uploaded with result: {upload_result}\n") - - # Delete the uploaded file - print("🗑️ Deleting test file...") - delete_result = api.delete_files(upload_result.file_key) + png_file = BytesIO(image_content) + png_file.name = "test.png" + + # Prepare Jpeg file + with open("./examples/test.jpg", "rb") as f: + image_content = f.read() + jpeg_file = BytesIO(image_content) + jpeg_file.name = "test.jpg" + + # Upload both files + upload_results: List[UploadResult] = api.upload_files( + [png_file, jpeg_file], acl="public-read" + ) + + print("Upload results:") + for result in upload_results: + print(f"- {result.name}: {result.file_key}") + print() + + # Delete the uploaded files + print("🗑️ Deleting test files...") + file_keys = [result.file_key for result in upload_results] + delete_result = api.delete_files(file_keys) print(f"Deleted {delete_result.deleted_count} file(s)") print(f"Success: {delete_result.success}\n") diff --git a/examples/test.jpg b/examples/test.jpg new file mode 100644 index 0000000..71911bf Binary files /dev/null and b/examples/test.jpg differ diff --git a/poetry.lock b/poetry.lock index 625773b..f2d6697 100644 --- a/poetry.lock +++ b/poetry.lock @@ -473,6 +473,25 @@ pluggy = ">=1.5,<2" [package.extras] dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] +[[package]] +name = "pytest-asyncio" +version = "0.23.8" +description = "Pytest support for asyncio" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "pytest_asyncio-0.23.8-py3-none-any.whl", hash = "sha256:50265d892689a5faefb84df80819d1ecef566eb3549cf915dfb33569359d1ce2"}, + {file = "pytest_asyncio-0.23.8.tar.gz", hash = "sha256:759b10b33a6dc61cce40a8bd5205e302978bbbcc00e279a8b61d9a6a3c82e4d3"}, +] + +[package.dependencies] +pytest = ">=7.0.0,<9" + +[package.extras] +docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1.0)"] +testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] + [[package]] name = "respx" version = "0.22.0" @@ -588,4 +607,4 @@ files = [ [metadata] lock-version = "2.1" python-versions = ">=3.12" -content-hash = "ebc01cb3e7454a725a043b97f5ce026d468c708dd6e7441f19c6a2ac4e3f19c1" +content-hash = "b58b1ca978611dc0460d0112653c1184624ccdcbc506aa5850fe182490016634" diff --git a/pyproject.toml b/pyproject.toml index 1c1a52f..a3a9746 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,7 @@ pyright = "^1.1.393" ptpython = "^3.0.29" pytest = "^8.3.4" respx = "^0.22.0" +pytest-asyncio = "^0.23.2" [tool.ruff] line-length = 79 diff --git a/tests/test_async_client.py b/tests/test_async_client.py new file mode 100644 index 0000000..42f055c --- /dev/null +++ b/tests/test_async_client.py @@ -0,0 +1,291 @@ +import base64 +import json +from io import BytesIO + +import httpx +import pytest +import respx + +from upyloadthing.async_client import AsyncUTApi +from upyloadthing.base_client import API_URL +from upyloadthing.schemas import ( + DeleteFileResponse, + ListFileResponse, + UploadResult, + UsageInfoResponse, + UTApiOptions, +) + +# Test data +MOCK_TOKEN = base64.b64encode( + json.dumps( + { + "appId": "test-app", + "apiKey": "test-key", + "regions": ["sea2"], + } + ).encode() +).decode() + + +@pytest.fixture +def ut_api(): + """Fixture to create an AsyncUTApi instance with mock token.""" + options = UTApiOptions(token=MOCK_TOKEN) + return AsyncUTApi(options) + + +@pytest.fixture +def mock_file(): + """Fixture to create a mock file.""" + file = BytesIO(b"test content") + file.name = "test.jpg" + return file + + +def test_init_with_options(): + """Test AsyncUTApi initialization with options.""" + options = UTApiOptions(token=MOCK_TOKEN, region="eu-west-1") + api = AsyncUTApi(options) + assert api.token.app_id == "test-app" + assert api.token.api_key == "test-key" + assert api.region == "eu-west-1" + + +def test_init_without_token(): + """Test AsyncUTApi initialization without token raises error.""" + with pytest.raises(ValueError, match="UPLOADTHING_TOKEN is required"): + AsyncUTApi(UTApiOptions(token=None)) + + +def test_make_headers(ut_api): + """Test header generation.""" + headers = ut_api._make_headers() + assert headers["x-uploadthing-api-key"] == "test-key" + assert "x-uploadthing-version" in headers + assert "x-uploadthing-be-adapter" in headers + + +@pytest.mark.asyncio +@respx.mock +async def test_upload_files(respx_mock: respx.MockRouter, ut_api, mock_file): + """Test file upload functionality.""" + upload_response = { + "fileHash": "dae427dff5fa285fc87a791dc8b7daf1", + "url": "https://utfs.io/f/AlZ3KvVUSx6sMzg3ZDRmZmM5NTRhNDBkMmI5ZWQ5ODg2NWZmODc3MTg=", + "ufsUrl": "https://lhdsot44oz.ufs.sh/f/AlZ3KvVUSx6sMzg3ZDRmZmM5NTRhNDBkMmI5ZWQ5ODg2NWZmODc3MTg=", + "appUrl": "https://utfs.io/a/lhdsot44oz/AlZ3KvVUSx6sMzg3ZDRmZmM5NTRhNDBkMmI5ZWQ5ODg2NWZmODc3MTg=", + } + + respx_mock.put(url__regex="https://sea2.ingest.uploadthing.com/").mock( + return_value=httpx.Response(200, json=upload_response) + ) + + result = (await ut_api.upload_files(mock_file))[0] + assert result.file_key is not None + assert result.name == "test.jpg" + assert result.size == len(b"test content") + assert result.type == "image/jpeg" + assert result.file_hash == "dae427dff5fa285fc87a791dc8b7daf1" + assert ( + result.url + == "https://utfs.io/f/AlZ3KvVUSx6sMzg3ZDRmZmM5NTRhNDBkMmI5ZWQ5ODg2NWZmODc3MTg=" + ) + assert ( + result.ufs_url + == "https://lhdsot44oz.ufs.sh/f/AlZ3KvVUSx6sMzg3ZDRmZmM5NTRhNDBkMmI5ZWQ5ODg2NWZmODc3MTg=" + ) + assert ( + result.app_url + == "https://utfs.io/a/lhdsot44oz/AlZ3KvVUSx6sMzg3ZDRmZmM5NTRhNDBkMmI5ZWQ5ODg2NWZmODc3MTg=" + ) + assert result.server_data is None + + +@pytest.mark.asyncio +@respx.mock +async def test_upload_multiple_files(respx_mock: respx.MockRouter, ut_api): + """Test uploading multiple files.""" + files = [BytesIO(b"test1 content"), BytesIO(b"test2 content")] + for i, f in enumerate(files): + f.name = f"test{i + 1}.jpg" + + upload_response = { + "fileHash": "dae427dff5fa285fc87a791dc8b7daf1", + "url": "https://utfs.io/f/AlZ3KvVUSx6sMzg3ZDRmZmM5NTRhNDBkMmI5ZWQ5ODg2NWZmODc3MTg=", + "ufsUrl": "https://lhdsot44oz.ufs.sh/f/AlZ3KvVUSx6sMzg3ZDRmZmM5NTRhNDBkMmI5ZWQ5ODg2NWZmODc3MTg=", + "appUrl": "https://utfs.io/a/lhdsot44oz/AlZ3KvVUSx6sMzg3ZDRmZmM5NTRhNDBkMmI5ZWQ5ODg2NWZmODc3MTg=", + } + + respx_mock.put(url__regex="https://sea2.ingest.uploadthing.com/").mock( + return_value=httpx.Response(200, json=upload_response) + ) + + result = await ut_api.upload_files(files) + assert isinstance(result, list) + assert len(result) == 2 + assert all(isinstance(r, UploadResult) for r in result) + + +@pytest.mark.asyncio +@respx.mock +async def test_upload_multiple_files_with_different_types( + respx_mock: respx.MockRouter, ut_api +): + """Test uploading multiple files with different types.""" + # Create test files of different types + png_file = BytesIO(b"fake png content") + png_file.name = "test.png" + + jpg_file = BytesIO(b"fake jpg content") + jpg_file.name = "test.jpg" + + files = [png_file, jpg_file] + + upload_response = { + "fileHash": "dae427dff5fa285fc87a791dc8b7daf1", + "url": "https://utfs.io/f/AlZ3KvVUSx6sMzg3ZDRmZmM5NTRhNDBkMmI5ZWQ5ODg2NWZmODc3MTg=", + "ufsUrl": "https://lhdsot44oz.ufs.sh/f/AlZ3KvVUSx6sMzg3ZDRmZmM5NTRhNDBkMmI5ZWQ5ODg2NWZmODc3MTg=", + "appUrl": "https://utfs.io/a/lhdsot44oz/AlZ3KvVUSx6sMzg3ZDRmZmM5NTRhNDBkMmI5ZWQ5ODg2NWZmODc3MTg=", + } + + respx_mock.put(url__regex="https://sea2.ingest.uploadthing.com/").mock( + return_value=httpx.Response(200, json=upload_response) + ) + + result = await ut_api.upload_files(files) + assert isinstance(result, list) + assert len(result) == 2 + assert all(isinstance(r, UploadResult) for r in result) + + # Verify file specific details + assert result[0].name == "test.png" + assert result[0].type == "image/png" + assert result[1].name == "test.jpg" + assert result[1].type == "image/jpeg" + + +@pytest.mark.asyncio +@respx.mock +async def test_upload_multiple_files_with_custom_disposition( + respx_mock: respx.MockRouter, ut_api +): + """Test uploading multiple files with custom content disposition.""" + files = [BytesIO(b"test1 content"), BytesIO(b"test2 content")] + for i, f in enumerate(files): + f.name = f"test{i + 1}.txt" + + upload_response = { + "fileHash": "dae427dff5fa285fc87a791dc8b7daf1", + "url": "https://utfs.io/f/AlZ3KvVUSx6sMzg3ZDRmZmM5NTRhNDBkMmI5ZWQ5ODg2NWZmODc3MTg=", + "ufsUrl": "https://lhdsot44oz.ufs.sh/f/AlZ3KvVUSx6sMzg3ZDRmZmM5NTRhNDBkMmI5ZWQ5ODg2NWZmODc3MTg=", + "appUrl": "https://utfs.io/a/lhdsot44oz/AlZ3KvVUSx6sMzg3ZDRmZmM5NTRhNDBkMmI5ZWQ5ODg2NWZmODc3MTg=", + } + + respx_mock.put(url__regex="https://sea2.ingest.uploadthing.com/").mock( + return_value=httpx.Response(200, json=upload_response) + ) + + result = await ut_api.upload_files(files, content_disposition="attachment") + assert isinstance(result, list) + assert len(result) == 2 + assert all(isinstance(r, UploadResult) for r in result) + + +@pytest.mark.asyncio +@respx.mock +async def test_delete_files(respx_mock: respx.MockRouter, ut_api): + """Test file deletion.""" + delete_response = {"success": True, "deleted_count": 1} + + respx_mock.post(f"{API_URL}/v6/deleteFiles").mock( + return_value=httpx.Response(200, json=delete_response) + ) + + result = await ut_api.delete_files( + "AlZ3KvVUSx6sMzg3ZDRmZmM5NTRhNDBkMmI5ZWQ5ODg2NWZmODc3MTg=" + ) + assert isinstance(result, DeleteFileResponse) + assert result.success is True + assert result.deleted_count == 1 + + +@pytest.mark.asyncio +@respx.mock +async def test_list_files(respx_mock: respx.MockRouter, ut_api): + """Test file listing.""" + list_response = { + "has_more": False, + "files": [ + { + "id": "file_123", + "key": "AlZ3KvVUSx6sMzg3ZDRmZmM5NTRhNDBkMmI5ZWQ5ODg2NWZmODc3MTg=", # noqa: E501 + "name": "test.jpg", + "status": "ready", + "size": 1024, + "uploaded_at": 1704067200, + } + ], + } + + respx_mock.post(f"{API_URL}/v6/listFiles").mock( + return_value=httpx.Response(200, json=list_response) + ) + + result = await ut_api.list_files(limit=10, offset=0) + assert isinstance(result, ListFileResponse) + assert len(result.files) == 1 + assert result.has_more is False + assert result.files[0].id == "file_123" + assert result.files[0].status == "ready" + + +@pytest.mark.asyncio +@respx.mock +async def test_get_usage_info(respx_mock: respx.MockRouter, ut_api): + """Test usage info retrieval.""" + usage_response = { + "total_bytes": 1024, + "app_total_bytes": 2048, + "files_uploaded": 10, + "limit_bytes": 5000000, + } + + respx_mock.post(f"{API_URL}/v6/getUsageInfo").mock( + return_value=httpx.Response(200, json=usage_response) + ) + + result = await ut_api.get_usage_info() + assert isinstance(result, UsageInfoResponse) + assert result.total_bytes == 1024 + assert result.app_total_bytes == 2048 + assert result.files_uploaded == 10 + assert result.limit_bytes == 5000000 + + +@pytest.mark.asyncio +@respx.mock +async def test_request_with_error(respx_mock: respx.MockRouter, ut_api): + """Test handling of request errors.""" + respx_mock.get(f"{API_URL}/test").mock( + return_value=httpx.Response(400, json={"error": "Bad Request"}) + ) + + with pytest.raises(httpx.HTTPError): + await ut_api._request("GET", "/test") + + +@pytest.mark.asyncio +@respx.mock +async def test_request_with_different_content_types( + respx_mock: respx.MockRouter, ut_api +): + """Test requests with different content types.""" + test_response = {"key": "value"} + + respx_mock.post(f"{API_URL}/test-json").mock( + return_value=httpx.Response(200, json=test_response) + ) + + result = await ut_api._request("POST", "/test-json", {"data": "test"}) + assert result == {"key": "value"} diff --git a/tests/test_client.py b/tests/test_client.py index b3fccec..b0e311a 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -6,7 +6,8 @@ import pytest import respx -from upyloadthing.client import API_URL, UTApi +from upyloadthing.base_client import API_URL +from upyloadthing.client import UTApi from upyloadthing.schemas import ( DeleteFileResponse, ListFileResponse, @@ -81,10 +82,7 @@ def test_upload_files(respx_mock: respx.MockRouter, ut_api, mock_file): return_value=httpx.Response(200, json=upload_response) ) - result = ut_api.upload_files(mock_file) - - # Verify result is correct type - assert isinstance(result, UploadResult) + result = ut_api.upload_files(mock_file)[0] # Verify all fields assert result.file_key is not None # Generated dynamically @@ -137,6 +135,69 @@ def test_upload_multiple_files(respx_mock: respx.MockRouter, ut_api): ) +@respx.mock() +def test_upload_multiple_files_with_different_types( + respx_mock: respx.MockRouter, ut_api +): + """Test uploading multiple files with different types.""" + # Create test files of different types + png_file = BytesIO(b"fake png content") + png_file.name = "test.png" + + jpg_file = BytesIO(b"fake jpg content") + jpg_file.name = "test.jpg" + + files = [png_file, jpg_file] + + upload_response = { + "fileHash": "dae427dff5fa285fc87a791dc8b7daf1", + "url": "https://utfs.io/f/AlZ3KvVUSx6sMzg3ZDRmZmM5NTRhNDBkMmI5ZWQ5ODg2NWZmODc3MTg=", + "ufsUrl": "https://lhdsot44oz.ufs.sh/f/AlZ3KvVUSx6sMzg3ZDRmZmM5NTRhNDBkMmI5ZWQ5ODg2NWZmODc3MTg=", + "appUrl": "https://utfs.io/a/lhdsot44oz/AlZ3KvVUSx6sMzg3ZDRmZmM5NTRhNDBkMmI5ZWQ5ODg2NWZmODc3MTg=", + } + + respx_mock.put(url__regex="https://sea2.ingest.uploadthing.com/").mock( + return_value=httpx.Response(200, json=upload_response) + ) + + result = ut_api.upload_files(files) + assert isinstance(result, list) + assert len(result) == 2 + assert all(isinstance(r, UploadResult) for r in result) + + # Verify file specific details + assert result[0].name == "test.png" + assert result[0].type == "image/png" + assert result[1].name == "test.jpg" + assert result[1].type == "image/jpeg" + + +@respx.mock() +def test_upload_multiple_files_with_custom_disposition( + respx_mock: respx.MockRouter, ut_api +): + """Test uploading multiple files with custom content disposition.""" + files = [BytesIO(b"test1 content"), BytesIO(b"test2 content")] + for i, f in enumerate(files): + f.name = f"test{i + 1}.txt" + + upload_response = { + "fileHash": "dae427dff5fa285fc87a791dc8b7daf1", + "url": "https://utfs.io/f/AlZ3KvVUSx6sMzg3ZDRmZmM5NTRhNDBkMmI5ZWQ5ODg2NWZmODc3MTg=", + "ufsUrl": "https://lhdsot44oz.ufs.sh/f/AlZ3KvVUSx6sMzg3ZDRmZmM5NTRhNDBkMmI5ZWQ5ODg2NWZmODc3MTg=", + "appUrl": "https://utfs.io/a/lhdsot44oz/AlZ3KvVUSx6sMzg3ZDRmZmM5NTRhNDBkMmI5ZWQ5ODg2NWZmODc3MTg=", + } + + respx_mock.put(url__regex="https://sea2.ingest.uploadthing.com/").mock( + return_value=httpx.Response(200, json=upload_response) + ) + + result = ut_api.upload_files(files, content_disposition="attachment") + assert isinstance(result, list) + assert len(result) == 2 + assert all(isinstance(r, UploadResult) for r in result) + + @respx.mock() def test_delete_files(respx_mock: respx.MockRouter, ut_api): """Test file deletion.""" diff --git a/upyloadthing/async_client.py b/upyloadthing/async_client.py new file mode 100644 index 0000000..4d5c6b9 --- /dev/null +++ b/upyloadthing/async_client.py @@ -0,0 +1,176 @@ +import asyncio +from typing import BinaryIO, List + +import httpx + +from upyloadthing.base_client import BaseUTApi +from upyloadthing.schemas import ( + DeleteFileResponse, + ListFileResponse, + UploadResult, + UsageInfoResponse, +) +from upyloadthing.utils import snakify + + +class AsyncUTApi(BaseUTApi): + """Asynchronous UploadThing API client. + + This class provides asynchronous methods for interacting with the + UploadThing API. Use this client for async/await operations. + """ + + async def _request( + self, + method: str, + path: str, + data: dict | None = None, + timeout: float = 30.0, + ) -> dict: + """Make an async HTTP request to the UploadThing API. + + Args: + method: HTTP method to use + path: API endpoint path + data: Request data/parameters + timeout: Request timeout in seconds + + Returns: + dict: Parsed JSON response + + Raises: + httpx.TimeoutException: If the request times out + httpx.HTTPStatusError: If the server returns an error status + """ + url, headers, request_kwargs = self._prepare_request( + method, path, data + ) + request_kwargs = request_kwargs or {} + request_kwargs.update({"timeout": timeout}) + + try: + async with httpx.AsyncClient() as client: + response = await client.request( + method=method, url=url, headers=headers, **request_kwargs + ) + response.raise_for_status() + result = snakify(response.json()) + if isinstance(result, dict): # Type guard + return result + raise TypeError("Expected dict response") + except httpx.TimeoutException: + raise httpx.TimeoutException( + f"Request to {url} timed out after {timeout} seconds" + ) from None + except httpx.HTTPStatusError as e: + self._handle_error_response(e) + raise # This ensures we always return or raise + + async def _upload_single_file(self, file_data: dict) -> UploadResult: + """Upload a single file to UploadThing. + + Args: + file_data: Dictionary containing file metadata and content + + Returns: + UploadResult: Result of the upload operation + """ + result = await self._request( + "PUT", + file_data["ingest_url"], + data={ + "file": ( + file_data["name"], + file_data["file"], + file_data["type"], + ) + }, + ) + + return UploadResult( + file_key=file_data["file_key"], + name=file_data["name"], + size=file_data["size"], + type=file_data["type"], + **result, + ) + + async def upload_files( + self, + files: BinaryIO | List[BinaryIO], + content_disposition: str = "inline", + acl: str | None = "public-read", + ) -> List[UploadResult]: + """Upload one or more files to UploadThing asynchronously. + + Args: + files: Single file or list of files to upload (file-like objects) + content_disposition: Content disposition header value ('inline' or 'attachment') + acl: Access control list setting for uploaded files + + Returns: + List[UploadResult]: List of upload results containing file information + """ # noqa: E501 + if not isinstance(files, list): + files = [files] + + files_data = [ + self._prepare_file_data(file, content_disposition, acl) + for file in files + ] + + # Upload all files in parallel + results = await asyncio.gather( + *[self._upload_single_file(file_data) for file_data in files_data] + ) + + return results + + async def delete_files( + self, keys: str | List[str], key_type: str | None = "file_key" + ) -> DeleteFileResponse: + """Delete one or more files from UploadThing asynchronously. + + Args: + keys: Single key or list of keys identifying files to delete + key_type: Type of key provided ('file_key' or 'custom_id') + + Returns: + DeleteFileResponse: Response containing deletion results + """ + keys_list = [keys] if isinstance(keys, str) else keys + data = { + "fileKeys" if key_type == "file_key" else "customIds": keys_list + } + result = await self._request("POST", "/v6/deleteFiles", data) + return DeleteFileResponse(**result) + + async def list_files( + self, limit: int | None = None, offset: int | None = None + ) -> ListFileResponse: + """List files stored in UploadThing asynchronously. + + Args: + limit: Maximum number of files to return + offset: Number of files to skip for pagination + + Returns: + ListFileResponse: Response containing list of files + """ + params = {} + if limit: + params["limit"] = limit + if offset: + params["offset"] = offset + + response = await self._request("POST", "/v6/listFiles", params) + return ListFileResponse(**response) + + async def get_usage_info(self) -> UsageInfoResponse: + """Get usage information for the UploadThing account asynchronously. + + Returns: + UsageInfoResponse: Response containing usage statistics + """ + result = await self._request("POST", "/v6/getUsageInfo") + return UsageInfoResponse(**result) diff --git a/upyloadthing/base_client.py b/upyloadthing/base_client.py new file mode 100644 index 0000000..e512be6 --- /dev/null +++ b/upyloadthing/base_client.py @@ -0,0 +1,242 @@ +import base64 +import json +import mimetypes +import os +import uuid +from abc import ABC, abstractmethod +from typing import Any, BinaryIO, Coroutine, List + +import httpx + +from upyloadthing.file_key import generate_key +from upyloadthing.presign import make_presigned_url +from upyloadthing.schemas import ( + DeleteFileResponse, + ListFileResponse, + UploadResult, + UsageInfoResponse, + UTApiOptions, + UTtoken, +) +from upyloadthing.utils import snakify + +SDK_VERSION = "7.4.4" +BE_ADAPTER = "server-sdk" +API_URL = "https://api.uploadthing.com" + + +class BaseUTApi(ABC): + """Base class for UploadThing API client. + + This abstract class defines the interface for both synchronous and + asynchronous clients. + """ + + def __init__(self, options: UTApiOptions | None = None): + self.options = options or UTApiOptions() + b64_token = ( + options.token if options else os.getenv("UPLOADTHING_TOKEN") + ) + if not b64_token: + raise ValueError("UPLOADTHING_TOKEN is required") + decoded_token = snakify( + json.loads(base64.b64decode(b64_token).decode("utf-8")) + ) + self.token = UTtoken.model_validate(decoded_token) + self.region = ( + options.region + if options and options.region + else os.getenv("UPLOADTHING_REGION") or self.token.regions[0] + ) + self.api_url = API_URL + + def _make_headers(self) -> dict: + """Create headers required for UploadThing API requests. + + Returns: + dict: Headers containing SDK version, adapter type, and API key + """ + return { + "x-uploadthing-version": SDK_VERSION, + "x-uploadthing-be-adapter": BE_ADAPTER, + "x-uploadthing-api-key": self.token.api_key, + } + + @abstractmethod + def _request( + self, + method: str, + path: str, + data: dict | None = None, + ) -> dict | Coroutine[Any, Any, dict]: + """Make an HTTP request to the UploadThing API. + + Args: + method: HTTP method to use + path: API endpoint path + data: Request data/parameters + + Returns: + Response data as dictionary or coroutine + """ + pass + + @abstractmethod + def upload_files( + self, + files: BinaryIO | List[BinaryIO], + content_disposition: str = "inline", + acl: str | None = "public-read", + ) -> List[UploadResult] | Coroutine[Any, Any, List[UploadResult]]: + """Upload one or more files to UploadThing. + + Args: + files: Single file or list of files to upload + content_disposition: Content disposition header value + acl: Access control list setting for uploaded files + + Returns: + List of upload results or coroutine + """ + pass + + @abstractmethod + def delete_files( + self, keys: str | List[str], key_type: str | None = "file_key" + ) -> DeleteFileResponse | Coroutine[Any, Any, DeleteFileResponse]: + """Delete one or more files from UploadThing. + + Args: + keys: Single key or list of keys identifying files to delete + key_type: Type of key provided ('file_key' or 'custom_id') + + Returns: + Delete operation response or coroutine + """ + pass + + @abstractmethod + def list_files( + self, limit: int | None = None, offset: int | None = None + ) -> ListFileResponse | Coroutine[Any, Any, ListFileResponse]: + """List files stored in UploadThing. + + Args: + limit: Maximum number of files to return + offset: Number of files to skip + + Returns: + File listing response or coroutine + """ + pass + + @abstractmethod + def get_usage_info( + self, + ) -> UsageInfoResponse | Coroutine[Any, Any, UsageInfoResponse]: + """Get usage information for the UploadThing account. + + Returns: + Usage information response or coroutine + """ + pass + + def _prepare_file_data( + self, file: BinaryIO, content_disposition: str, acl: str | None + ) -> dict: + """Prepare file metadata and presigned URL for upload. + + Args: + file: File-like object to upload + content_disposition: Content disposition header value + acl: Access control list setting + + Returns: + dict: Prepared file data including presigned URL + """ + file_seed = uuid.uuid4().hex + file_key = generate_key(file_seed, self.token.app_id) + file_name = getattr(file, "name", f"upload_{uuid.uuid4()}") + file_size = file.seek(0, 2) + file.seek(0) + file_type = ( + mimetypes.guess_type(file_name)[0] or "application/octet-stream" + ) + + ingest_url = make_presigned_url( + self.region, + file_key, + self.token.api_key, + self.token.app_id, + file_name, + file_size, + file_type, + file_seed, + content_disposition, + acl, + ) + + return { + "file_key": file_key, + "name": file_name, + "size": file_size, + "type": file_type, + "custom_id": file_seed, + "file": file, + "ingest_url": ingest_url, + } + + def _prepare_request( + self, method: str, path: str, data: dict | None = None + ) -> tuple[str, dict, dict | None]: + """Prepare common request parameters. + + Args: + method: HTTP method to use + path: API endpoint path + data: Request data/parameters + + Returns: + tuple: (url, headers, prepared_data) + """ + if path.startswith("/"): + url = f"{self.api_url}{path}" + else: + url = path + + headers = self._make_headers() + + def is_file_like(obj): + return hasattr(obj, "read") and callable(obj.read) + + if isinstance(data, dict): + has_files = any( + isinstance(v, tuple) and len(v) >= 2 and is_file_like(v[1]) + for v in data.values() + ) + if has_files: + return url, headers, {"files": data} + return url, headers, {"json": data} + + return url, headers, None + + def _handle_error_response(self, e: httpx.HTTPStatusError) -> None: + """Handle HTTP error responses consistently. + + Args: + e: The HTTP error exception + + Raises: + httpx.HTTPStatusError: Enhanced error with more details + """ + error_msg = f"UploadThing API error: {e.response.status_code}" + try: + error_data = e.response.json() + if "error" in error_data: + error_msg += f" - {error_data['error']}" + except Exception: + pass + raise httpx.HTTPStatusError( + error_msg, request=e.request, response=e.response + ) from e + diff --git a/upyloadthing/client.py b/upyloadthing/client.py index 3ab4a85..07636cb 100644 --- a/upyloadthing/client.py +++ b/upyloadthing/client.py @@ -1,197 +1,96 @@ -import base64 -import json -import mimetypes -import os -import uuid from typing import BinaryIO, List import httpx -from upyloadthing.file_key import generate_key -from upyloadthing.presign import make_presigned_url +from upyloadthing.base_client import BaseUTApi from upyloadthing.schemas import ( DeleteFileResponse, ListFileResponse, UploadResult, UsageInfoResponse, - UTApiOptions, - UTtoken, ) from upyloadthing.utils import snakify -SDK_VERSION = "7.4.4" -BE_ADAPTER = "server-sdk" -API_URL = "https://api.uploadthing.com" +class UTApi(BaseUTApi): + """Synchronous UploadThing API client. -class UTApi: - """UploadThing API client for handling file uploads and management. - - This class provides methods to interact with the UploadThing service, - including file uploads, deletions, listing, and usage information. - - Args: - options (UTApiOptions | None): Configuration options for the API - client. - If not provided, will attempt to use environment variables. - - Raises: - ValueError: If UPLOADTHING_TOKEN is not provided either through options - or environment variables. + This class provides synchronous methods for interacting with the + UploadThing API. Use this client for standard synchronous operations. """ - def __init__(self, options: UTApiOptions | None = None): - self.options = options or UTApiOptions() - b64_token = ( - options.token if options else os.getenv("UPLOADTHING_TOKEN") - ) - if not b64_token: - raise ValueError("UPLOADTHING_TOKEN is required") - decoded_token = snakify( - json.loads(base64.b64decode(b64_token).decode("utf-8")) - ) - self.token = UTtoken.model_validate(decoded_token) - self.region = ( - options.region - if options and options.region - else os.getenv("UPLOADTHING_REGION") or self.token.regions[0] - ) - - self.api_url = API_URL - - def _make_headers(self) -> dict: - """Create headers required for API requests. - - Returns: - dict: Dictionary containing the required headers for UploadThing - API requests. - """ - headers = { - "x-uploadthing-version": SDK_VERSION, - "x-uploadthing-be-adapter": BE_ADAPTER, - "x-uploadthing-api-key": self.token.api_key, - } - return headers - def _request( self, method: str, path: str, data: dict | None = None, + timeout: float = 30.0, ) -> dict: """Make an HTTP request to the UploadThing API. Args: - method (str): HTTP method to use (GET, POST, PUT, etc.) - path (str): API endpoint path or full URL - data (dict | None): Request data. Can contain regular JSON data or - file objects. + method: HTTP method to use + path: API endpoint path + data: Request data/parameters + timeout: Request timeout in seconds Returns: - dict: JSON response from the API, converted to snake_case + dict: Parsed JSON response Raises: - httpx.HTTPError: If the HTTP request fails + httpx.TimeoutException: If the request times out + httpx.HTTPStatusError: If the server returns an error status """ - if path.startswith("/"): - url = f"{self.api_url}{path}" - else: - url = path - - headers = self._make_headers() - - # Helper function to check if value is a file-like object - def is_file_like(obj): - return hasattr(obj, "read") and callable(obj.read) - - # Check if data contains any file-like objects - has_files = False - if isinstance(data, dict): - has_files = any( - isinstance(v, tuple) and len(v) >= 2 and is_file_like(v[1]) - for v in data.values() - ) + url, headers, request_kwargs = self._prepare_request( + method, path, data + ) + request_kwargs = request_kwargs or {} + request_kwargs.update({"timeout": timeout}) - # Make the request based on the content type - with httpx.Client() as client: - if data is None: - response = client.request( - method=method, url=url, headers=headers - ) - elif has_files: - response = client.request( - method=method, url=url, headers=headers, files=data - ) - else: + try: + with httpx.Client() as client: response = client.request( - method=method, url=url, headers=headers, json=data + method=method, url=url, headers=headers, **request_kwargs ) - - response.raise_for_status() - return snakify(response.json()) + response.raise_for_status() + result = snakify(response.json()) + if isinstance(result, dict): # Type guard + return result + raise TypeError("Expected dict response") + except httpx.TimeoutException: + raise httpx.TimeoutException( + f"Request to {url} timed out after {timeout} seconds" + ) from None + except httpx.HTTPStatusError as e: + self._handle_error_response(e) + raise # This ensures we always return or raise def upload_files( self, files: BinaryIO | List[BinaryIO], content_disposition: str = "inline", acl: str | None = "public-read", - ) -> UploadResult | List[UploadResult]: - """Upload one or more files to UploadThing. + ) -> List[UploadResult]: + """Upload one or more files to UploadThing synchronously. Args: - files (BinaryIO | List[BinaryIO]): Single file or list of files to - upload - content_disposition (str, optional): Content-Disposition header - value. - acl (str | None, optional): Access control level for uploaded - files. + files: Single file or list of files to upload (file-like objects) + content_disposition: Content disposition header value ('inline' or 'attachment') + acl: Access control list setting for uploaded files Returns: - UploadResult | List[UploadResult]: Upload result(s) containing file - information. - Returns single UploadResult if one file was uploaded, or a list for - multiple files. - """ + List[UploadResult]: List of upload results containing file information + """ # noqa: E501 if not isinstance(files, list): files = [files] - files_data = [] - for file in files: - file_seed = uuid.uuid4().hex - file_key = generate_key(file_seed, self.token.app_id) - file_name = getattr(file, "name", f"upload_{uuid.uuid4()}") - file_size = file.seek(0, 2) - file.seek(0) - file_type = ( - mimetypes.guess_type(file_name)[0] - or "application/octet-stream" - ) - ingest_url = make_presigned_url( - self.region, - file_key, - self.token.api_key, - self.token.app_id, - file_name, - file_size, - file_type, - file_seed, - content_disposition, - acl, - ) - file_data = { - "file_key": file_key, - "name": file_name, - "size": file_size, - "type": file_type, - "custom_id": file_seed, - "file": file, - "ingest_url": ingest_url, - } - files_data.append(file_data) + files_data = [ + self._prepare_file_data(file, content_disposition, acl) + for file in files + ] results = [] for file_data in files_data: - print(f"Uploading {file_data}...") result = self._request( "PUT", file_data["ingest_url"], @@ -213,43 +112,38 @@ def upload_files( ) results.append(upload_result) - return results[0] if len(results) == 1 else results + return results def delete_files( self, keys: str | List[str], key_type: str | None = "file_key" ) -> DeleteFileResponse: - """Delete one or more files from UploadThing. + """Delete one or more files from UploadThing synchronously. Args: - keys (str | List[str]): Single key or list of keys identifying - files to delete - key_type (str | None, optional): Type of key provided ('file_key' - or 'custom_id'). + keys: Single key or list of keys identifying files to delete + key_type: Type of key provided ('file_key' or 'custom_id') + Returns: - DeleteFileResponse: Response containing information about the - deletion operation + DeleteFileResponse: Response containing deletion results """ keys_list = [keys] if isinstance(keys, str) else keys - data = { "fileKeys" if key_type == "file_key" else "customIds": keys_list } - result = self._request("POST", "/v6/deleteFiles", data) return DeleteFileResponse(**result) def list_files( self, limit: int | None = None, offset: int | None = None ) -> ListFileResponse: - """List files stored in UploadThing. + """List files stored in UploadThing synchronously. Args: - limit (int | None, optional): Maximum number of files to return. - offset (int | None, optional): Number of files to skip. + limit: Maximum number of files to return + offset: Number of files to skip for pagination Returns: - ListFileResponse: Response containing list of files and pagination - information + ListFileResponse: Response containing list of files """ params = {} if limit: @@ -261,10 +155,10 @@ def list_files( return ListFileResponse(**response) def get_usage_info(self) -> UsageInfoResponse: - """Get usage information for the UploadThing account. + """Get usage information for the UploadThing account synchronously. Returns: - UsageInfoResponse: Response containing usage statistics and limits + UsageInfoResponse: Response containing usage statistics """ result = self._request("POST", "/v6/getUsageInfo") return UsageInfoResponse(**result)