diff --git a/LICENSE b/LICENSE index e7a4d16..2027861 100644 --- a/LICENSE +++ b/LICENSE @@ -186,7 +186,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2025 Image Kit + Copyright 2026 Image Kit Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/scripts/lint b/scripts/lint index eb9a4dd..d4778c6 100755 --- a/scripts/lint +++ b/scripts/lint @@ -4,8 +4,13 @@ set -e cd "$(dirname "$0")/.." -echo "==> Running lints" -rye run lint +if [ "$1" = "--fix" ]; then + echo "==> Running lints with --fix" + rye run fix:ruff +else + echo "==> Running lints" + rye run lint +fi echo "==> Making sure it imports" rye run python -c 'import imagekitio' diff --git a/src/imagekitio/_base_client.py b/src/imagekitio/_base_client.py index 384e7c0..a2f5b04 100644 --- a/src/imagekitio/_base_client.py +++ b/src/imagekitio/_base_client.py @@ -9,6 +9,7 @@ import inspect import logging import platform +import warnings import email.utils from types import TracebackType from random import random @@ -51,9 +52,11 @@ ResponseT, AnyMapping, PostParser, + BinaryTypes, RequestFiles, HttpxSendArgs, RequestOptions, + AsyncBinaryTypes, HttpxRequestFiles, ModelBuilderProtocol, not_given, @@ -477,8 +480,19 @@ def _build_request( retries_taken: int = 0, ) -> httpx.Request: if log.isEnabledFor(logging.DEBUG): - log.debug("Request options: %s", model_dump(options, exclude_unset=True)) - + log.debug( + "Request options: %s", + model_dump( + options, + exclude_unset=True, + # Pydantic v1 can't dump every type we support in content, so we exclude it for now. + exclude={ + "content", + } + if PYDANTIC_V1 + else {}, + ), + ) kwargs: dict[str, Any] = {} json_data = options.json_data @@ -532,7 +546,13 @@ def _build_request( is_body_allowed = options.method.lower() != "get" if is_body_allowed: - if isinstance(json_data, bytes): + if options.content is not None and json_data is not None: + raise TypeError("Passing both `content` and `json_data` is not supported") + if options.content is not None and files is not None: + raise TypeError("Passing both `content` and `files` is not supported") + if options.content is not None: + kwargs["content"] = options.content + elif isinstance(json_data, bytes): kwargs["content"] = json_data else: kwargs["json"] = json_data if is_given(json_data) else None @@ -1194,6 +1214,7 @@ def post( *, cast_to: Type[ResponseT], body: Body | None = None, + content: BinaryTypes | None = None, options: RequestOptions = {}, files: RequestFiles | None = None, stream: Literal[False] = False, @@ -1206,6 +1227,7 @@ def post( *, cast_to: Type[ResponseT], body: Body | None = None, + content: BinaryTypes | None = None, options: RequestOptions = {}, files: RequestFiles | None = None, stream: Literal[True], @@ -1219,6 +1241,7 @@ def post( *, cast_to: Type[ResponseT], body: Body | None = None, + content: BinaryTypes | None = None, options: RequestOptions = {}, files: RequestFiles | None = None, stream: bool, @@ -1231,13 +1254,25 @@ def post( *, cast_to: Type[ResponseT], body: Body | None = None, + content: BinaryTypes | None = None, options: RequestOptions = {}, files: RequestFiles | None = None, stream: bool = False, stream_cls: type[_StreamT] | None = None, ) -> ResponseT | _StreamT: + if body is not None and content is not None: + raise TypeError("Passing both `body` and `content` is not supported") + if files is not None and content is not None: + raise TypeError("Passing both `files` and `content` is not supported") + if isinstance(body, bytes): + warnings.warn( + "Passing raw bytes as `body` is deprecated and will be removed in a future version. " + "Please pass raw bytes via the `content` parameter instead.", + DeprecationWarning, + stacklevel=2, + ) opts = FinalRequestOptions.construct( - method="post", url=path, json_data=body, files=to_httpx_files(files), **options + method="post", url=path, json_data=body, content=content, files=to_httpx_files(files), **options ) return cast(ResponseT, self.request(cast_to, opts, stream=stream, stream_cls=stream_cls)) @@ -1247,9 +1282,24 @@ def patch( *, cast_to: Type[ResponseT], body: Body | None = None, + content: BinaryTypes | None = None, + files: RequestFiles | None = None, options: RequestOptions = {}, ) -> ResponseT: - opts = FinalRequestOptions.construct(method="patch", url=path, json_data=body, **options) + if body is not None and content is not None: + raise TypeError("Passing both `body` and `content` is not supported") + if files is not None and content is not None: + raise TypeError("Passing both `files` and `content` is not supported") + if isinstance(body, bytes): + warnings.warn( + "Passing raw bytes as `body` is deprecated and will be removed in a future version. " + "Please pass raw bytes via the `content` parameter instead.", + DeprecationWarning, + stacklevel=2, + ) + opts = FinalRequestOptions.construct( + method="patch", url=path, json_data=body, content=content, files=to_httpx_files(files), **options + ) return self.request(cast_to, opts) def put( @@ -1258,11 +1308,23 @@ def put( *, cast_to: Type[ResponseT], body: Body | None = None, + content: BinaryTypes | None = None, files: RequestFiles | None = None, options: RequestOptions = {}, ) -> ResponseT: + if body is not None and content is not None: + raise TypeError("Passing both `body` and `content` is not supported") + if files is not None and content is not None: + raise TypeError("Passing both `files` and `content` is not supported") + if isinstance(body, bytes): + warnings.warn( + "Passing raw bytes as `body` is deprecated and will be removed in a future version. " + "Please pass raw bytes via the `content` parameter instead.", + DeprecationWarning, + stacklevel=2, + ) opts = FinalRequestOptions.construct( - method="put", url=path, json_data=body, files=to_httpx_files(files), **options + method="put", url=path, json_data=body, content=content, files=to_httpx_files(files), **options ) return self.request(cast_to, opts) @@ -1272,9 +1334,19 @@ def delete( *, cast_to: Type[ResponseT], body: Body | None = None, + content: BinaryTypes | None = None, options: RequestOptions = {}, ) -> ResponseT: - opts = FinalRequestOptions.construct(method="delete", url=path, json_data=body, **options) + if body is not None and content is not None: + raise TypeError("Passing both `body` and `content` is not supported") + if isinstance(body, bytes): + warnings.warn( + "Passing raw bytes as `body` is deprecated and will be removed in a future version. " + "Please pass raw bytes via the `content` parameter instead.", + DeprecationWarning, + stacklevel=2, + ) + opts = FinalRequestOptions.construct(method="delete", url=path, json_data=body, content=content, **options) return self.request(cast_to, opts) def get_api_list( @@ -1714,6 +1786,7 @@ async def post( *, cast_to: Type[ResponseT], body: Body | None = None, + content: AsyncBinaryTypes | None = None, files: RequestFiles | None = None, options: RequestOptions = {}, stream: Literal[False] = False, @@ -1726,6 +1799,7 @@ async def post( *, cast_to: Type[ResponseT], body: Body | None = None, + content: AsyncBinaryTypes | None = None, files: RequestFiles | None = None, options: RequestOptions = {}, stream: Literal[True], @@ -1739,6 +1813,7 @@ async def post( *, cast_to: Type[ResponseT], body: Body | None = None, + content: AsyncBinaryTypes | None = None, files: RequestFiles | None = None, options: RequestOptions = {}, stream: bool, @@ -1751,13 +1826,25 @@ async def post( *, cast_to: Type[ResponseT], body: Body | None = None, + content: AsyncBinaryTypes | None = None, files: RequestFiles | None = None, options: RequestOptions = {}, stream: bool = False, stream_cls: type[_AsyncStreamT] | None = None, ) -> ResponseT | _AsyncStreamT: + if body is not None and content is not None: + raise TypeError("Passing both `body` and `content` is not supported") + if files is not None and content is not None: + raise TypeError("Passing both `files` and `content` is not supported") + if isinstance(body, bytes): + warnings.warn( + "Passing raw bytes as `body` is deprecated and will be removed in a future version. " + "Please pass raw bytes via the `content` parameter instead.", + DeprecationWarning, + stacklevel=2, + ) opts = FinalRequestOptions.construct( - method="post", url=path, json_data=body, files=await async_to_httpx_files(files), **options + method="post", url=path, json_data=body, content=content, files=await async_to_httpx_files(files), **options ) return await self.request(cast_to, opts, stream=stream, stream_cls=stream_cls) @@ -1767,9 +1854,29 @@ async def patch( *, cast_to: Type[ResponseT], body: Body | None = None, + content: AsyncBinaryTypes | None = None, + files: RequestFiles | None = None, options: RequestOptions = {}, ) -> ResponseT: - opts = FinalRequestOptions.construct(method="patch", url=path, json_data=body, **options) + if body is not None and content is not None: + raise TypeError("Passing both `body` and `content` is not supported") + if files is not None and content is not None: + raise TypeError("Passing both `files` and `content` is not supported") + if isinstance(body, bytes): + warnings.warn( + "Passing raw bytes as `body` is deprecated and will be removed in a future version. " + "Please pass raw bytes via the `content` parameter instead.", + DeprecationWarning, + stacklevel=2, + ) + opts = FinalRequestOptions.construct( + method="patch", + url=path, + json_data=body, + content=content, + files=await async_to_httpx_files(files), + **options, + ) return await self.request(cast_to, opts) async def put( @@ -1778,11 +1885,23 @@ async def put( *, cast_to: Type[ResponseT], body: Body | None = None, + content: AsyncBinaryTypes | None = None, files: RequestFiles | None = None, options: RequestOptions = {}, ) -> ResponseT: + if body is not None and content is not None: + raise TypeError("Passing both `body` and `content` is not supported") + if files is not None and content is not None: + raise TypeError("Passing both `files` and `content` is not supported") + if isinstance(body, bytes): + warnings.warn( + "Passing raw bytes as `body` is deprecated and will be removed in a future version. " + "Please pass raw bytes via the `content` parameter instead.", + DeprecationWarning, + stacklevel=2, + ) opts = FinalRequestOptions.construct( - method="put", url=path, json_data=body, files=await async_to_httpx_files(files), **options + method="put", url=path, json_data=body, content=content, files=await async_to_httpx_files(files), **options ) return await self.request(cast_to, opts) @@ -1792,9 +1911,19 @@ async def delete( *, cast_to: Type[ResponseT], body: Body | None = None, + content: AsyncBinaryTypes | None = None, options: RequestOptions = {}, ) -> ResponseT: - opts = FinalRequestOptions.construct(method="delete", url=path, json_data=body, **options) + if body is not None and content is not None: + raise TypeError("Passing both `body` and `content` is not supported") + if isinstance(body, bytes): + warnings.warn( + "Passing raw bytes as `body` is deprecated and will be removed in a future version. " + "Please pass raw bytes via the `content` parameter instead.", + DeprecationWarning, + stacklevel=2, + ) + opts = FinalRequestOptions.construct(method="delete", url=path, json_data=body, content=content, **options) return await self.request(cast_to, opts) def get_api_list( diff --git a/src/imagekitio/_client.py b/src/imagekitio/_client.py index 3b9f4ae..f41d306 100644 --- a/src/imagekitio/_client.py +++ b/src/imagekitio/_client.py @@ -4,14 +4,13 @@ import os import base64 -from typing import Any, Mapping +from typing import TYPE_CHECKING, Any, Mapping from typing_extensions import Self, override import httpx from . import _exceptions from ._qs import Querystring -from .lib import helper from ._types import ( Omit, Headers, @@ -23,8 +22,8 @@ not_given, ) from ._utils import is_given, get_async_library +from ._compat import cached_property from ._version import __version__ -from .resources import dummy, assets, webhooks, custom_metadata_fields from ._streaming import Stream as Stream, AsyncStream as AsyncStream from ._exceptions import ImageKitError, APIStatusError from ._base_client import ( @@ -32,11 +31,19 @@ SyncAPIClient, AsyncAPIClient, ) -from .resources.beta import beta -from .resources.cache import cache -from .resources.files import files -from .resources.folders import folders -from .resources.accounts import accounts + +if TYPE_CHECKING: + from .resources import beta, cache, dummy, files, assets, folders, accounts, custom_metadata_fields + from .resources.dummy import DummyResource, AsyncDummyResource + from .resources.assets import AssetsResource, AsyncAssetsResource + from .resources.webhooks import WebhooksResource, AsyncWebhooksResource + from .resources.beta.beta import BetaResource, AsyncBetaResource + from .resources.cache.cache import CacheResource, AsyncCacheResource + from .resources.files.files import FilesResource, AsyncFilesResource + from .resources.folders.folders import FoldersResource, AsyncFoldersResource + from .resources.accounts.accounts import AccountsResource, AsyncAccountsResource + from .resources.custom_metadata_fields import CustomMetadataFieldsResource, AsyncCustomMetadataFieldsResource + from .lib.helper import HelperResource, AsyncHelperResource __all__ = [ "Timeout", @@ -51,19 +58,6 @@ class ImageKit(SyncAPIClient): - dummy: dummy.DummyResource - custom_metadata_fields: custom_metadata_fields.CustomMetadataFieldsResource - files: files.FilesResource - assets: assets.AssetsResource - cache: cache.CacheResource - folders: folders.FoldersResource - accounts: accounts.AccountsResource - beta: beta.BetaResource - webhooks: webhooks.WebhooksResource - helper: helper.HelperResource - with_raw_response: ImageKitWithRawResponse - with_streaming_response: ImageKitWithStreamedResponse - # client options private_key: str password: str | None @@ -134,18 +128,73 @@ def __init__( _strict_response_validation=_strict_response_validation, ) - self.dummy = dummy.DummyResource(self) - self.custom_metadata_fields = custom_metadata_fields.CustomMetadataFieldsResource(self) - self.files = files.FilesResource(self) - self.assets = assets.AssetsResource(self) - self.cache = cache.CacheResource(self) - self.folders = folders.FoldersResource(self) - self.accounts = accounts.AccountsResource(self) - self.beta = beta.BetaResource(self) - self.webhooks = webhooks.WebhooksResource(self) - self.helper = helper.HelperResource(self) - self.with_raw_response = ImageKitWithRawResponse(self) - self.with_streaming_response = ImageKitWithStreamedResponse(self) + @cached_property + def dummy(self) -> DummyResource: + from .resources.dummy import DummyResource + + return DummyResource(self) + + @cached_property + def custom_metadata_fields(self) -> CustomMetadataFieldsResource: + from .resources.custom_metadata_fields import CustomMetadataFieldsResource + + return CustomMetadataFieldsResource(self) + + @cached_property + def files(self) -> FilesResource: + from .resources.files import FilesResource + + return FilesResource(self) + + @cached_property + def assets(self) -> AssetsResource: + from .resources.assets import AssetsResource + + return AssetsResource(self) + + @cached_property + def cache(self) -> CacheResource: + from .resources.cache import CacheResource + + return CacheResource(self) + + @cached_property + def folders(self) -> FoldersResource: + from .resources.folders import FoldersResource + + return FoldersResource(self) + + @cached_property + def accounts(self) -> AccountsResource: + from .resources.accounts import AccountsResource + + return AccountsResource(self) + + @cached_property + def beta(self) -> BetaResource: + from .resources.beta import BetaResource + + return BetaResource(self) + + @cached_property + def webhooks(self) -> WebhooksResource: + from .resources.webhooks import WebhooksResource + + return WebhooksResource(self) + + @cached_property + def helper(self) -> HelperResource: + from .lib.helper import HelperResource + + return HelperResource(self) + + @cached_property + def with_raw_response(self) -> ImageKitWithRawResponse: + return ImageKitWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> ImageKitWithStreamedResponse: + return ImageKitWithStreamedResponse(self) @property @override @@ -172,9 +221,7 @@ def default_headers(self) -> dict[str, str | Omit]: @override def _validate_headers(self, headers: Headers, custom_headers: Headers) -> None: - if self.private_key and self.password and headers.get("Authorization"): - return - if isinstance(custom_headers.get("Authorization"), Omit): + if headers.get("Authorization") or isinstance(custom_headers.get("Authorization"), Omit): return raise TypeError( @@ -273,19 +320,6 @@ def _make_status_error( class AsyncImageKit(AsyncAPIClient): - dummy: dummy.AsyncDummyResource - custom_metadata_fields: custom_metadata_fields.AsyncCustomMetadataFieldsResource - files: files.AsyncFilesResource - assets: assets.AsyncAssetsResource - cache: cache.AsyncCacheResource - folders: folders.AsyncFoldersResource - accounts: accounts.AsyncAccountsResource - beta: beta.AsyncBetaResource - webhooks: webhooks.AsyncWebhooksResource - helper: helper.AsyncHelperResource - with_raw_response: AsyncImageKitWithRawResponse - with_streaming_response: AsyncImageKitWithStreamedResponse - # client options private_key: str password: str | None @@ -356,18 +390,73 @@ def __init__( _strict_response_validation=_strict_response_validation, ) - self.dummy = dummy.AsyncDummyResource(self) - self.custom_metadata_fields = custom_metadata_fields.AsyncCustomMetadataFieldsResource(self) - self.files = files.AsyncFilesResource(self) - self.assets = assets.AsyncAssetsResource(self) - self.cache = cache.AsyncCacheResource(self) - self.folders = folders.AsyncFoldersResource(self) - self.accounts = accounts.AsyncAccountsResource(self) - self.beta = beta.AsyncBetaResource(self) - self.webhooks = webhooks.AsyncWebhooksResource(self) - self.helper = helper.AsyncHelperResource(self) - self.with_raw_response = AsyncImageKitWithRawResponse(self) - self.with_streaming_response = AsyncImageKitWithStreamedResponse(self) + @cached_property + def dummy(self) -> AsyncDummyResource: + from .resources.dummy import AsyncDummyResource + + return AsyncDummyResource(self) + + @cached_property + def custom_metadata_fields(self) -> AsyncCustomMetadataFieldsResource: + from .resources.custom_metadata_fields import AsyncCustomMetadataFieldsResource + + return AsyncCustomMetadataFieldsResource(self) + + @cached_property + def files(self) -> AsyncFilesResource: + from .resources.files import AsyncFilesResource + + return AsyncFilesResource(self) + + @cached_property + def assets(self) -> AsyncAssetsResource: + from .resources.assets import AsyncAssetsResource + + return AsyncAssetsResource(self) + + @cached_property + def cache(self) -> AsyncCacheResource: + from .resources.cache import AsyncCacheResource + + return AsyncCacheResource(self) + + @cached_property + def folders(self) -> AsyncFoldersResource: + from .resources.folders import AsyncFoldersResource + + return AsyncFoldersResource(self) + + @cached_property + def accounts(self) -> AsyncAccountsResource: + from .resources.accounts import AsyncAccountsResource + + return AsyncAccountsResource(self) + + @cached_property + def beta(self) -> AsyncBetaResource: + from .resources.beta import AsyncBetaResource + + return AsyncBetaResource(self) + + @cached_property + def webhooks(self) -> AsyncWebhooksResource: + from .resources.webhooks import AsyncWebhooksResource + + return AsyncWebhooksResource(self) + + @cached_property + def helper(self) -> AsyncHelperResource: + from .lib.helper import AsyncHelperResource + + return AsyncHelperResource(self) + + @cached_property + def with_raw_response(self) -> AsyncImageKitWithRawResponse: + return AsyncImageKitWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncImageKitWithStreamedResponse: + return AsyncImageKitWithStreamedResponse(self) @property @override @@ -394,9 +483,7 @@ def default_headers(self) -> dict[str, str | Omit]: @override def _validate_headers(self, headers: Headers, custom_headers: Headers) -> None: - if self.private_key and self.password and headers.get("Authorization"): - return - if isinstance(custom_headers.get("Authorization"), Omit): + if headers.get("Authorization") or isinstance(custom_headers.get("Authorization"), Omit): return raise TypeError( @@ -495,59 +582,223 @@ def _make_status_error( class ImageKitWithRawResponse: + _client: ImageKit + def __init__(self, client: ImageKit) -> None: - self.dummy = dummy.DummyResourceWithRawResponse(client.dummy) - self.custom_metadata_fields = custom_metadata_fields.CustomMetadataFieldsResourceWithRawResponse( - client.custom_metadata_fields - ) - self.files = files.FilesResourceWithRawResponse(client.files) - self.assets = assets.AssetsResourceWithRawResponse(client.assets) - self.cache = cache.CacheResourceWithRawResponse(client.cache) - self.folders = folders.FoldersResourceWithRawResponse(client.folders) - self.accounts = accounts.AccountsResourceWithRawResponse(client.accounts) - self.beta = beta.BetaResourceWithRawResponse(client.beta) + self._client = client + + @cached_property + def dummy(self) -> dummy.DummyResourceWithRawResponse: + from .resources.dummy import DummyResourceWithRawResponse + + return DummyResourceWithRawResponse(self._client.dummy) + + @cached_property + def custom_metadata_fields(self) -> custom_metadata_fields.CustomMetadataFieldsResourceWithRawResponse: + from .resources.custom_metadata_fields import CustomMetadataFieldsResourceWithRawResponse + + return CustomMetadataFieldsResourceWithRawResponse(self._client.custom_metadata_fields) + + @cached_property + def files(self) -> files.FilesResourceWithRawResponse: + from .resources.files import FilesResourceWithRawResponse + + return FilesResourceWithRawResponse(self._client.files) + + @cached_property + def assets(self) -> assets.AssetsResourceWithRawResponse: + from .resources.assets import AssetsResourceWithRawResponse + + return AssetsResourceWithRawResponse(self._client.assets) + + @cached_property + def cache(self) -> cache.CacheResourceWithRawResponse: + from .resources.cache import CacheResourceWithRawResponse + + return CacheResourceWithRawResponse(self._client.cache) + + @cached_property + def folders(self) -> folders.FoldersResourceWithRawResponse: + from .resources.folders import FoldersResourceWithRawResponse + + return FoldersResourceWithRawResponse(self._client.folders) + + @cached_property + def accounts(self) -> accounts.AccountsResourceWithRawResponse: + from .resources.accounts import AccountsResourceWithRawResponse + + return AccountsResourceWithRawResponse(self._client.accounts) + + @cached_property + def beta(self) -> beta.BetaResourceWithRawResponse: + from .resources.beta import BetaResourceWithRawResponse + + return BetaResourceWithRawResponse(self._client.beta) class AsyncImageKitWithRawResponse: + _client: AsyncImageKit + def __init__(self, client: AsyncImageKit) -> None: - self.dummy = dummy.AsyncDummyResourceWithRawResponse(client.dummy) - self.custom_metadata_fields = custom_metadata_fields.AsyncCustomMetadataFieldsResourceWithRawResponse( - client.custom_metadata_fields - ) - self.files = files.AsyncFilesResourceWithRawResponse(client.files) - self.assets = assets.AsyncAssetsResourceWithRawResponse(client.assets) - self.cache = cache.AsyncCacheResourceWithRawResponse(client.cache) - self.folders = folders.AsyncFoldersResourceWithRawResponse(client.folders) - self.accounts = accounts.AsyncAccountsResourceWithRawResponse(client.accounts) - self.beta = beta.AsyncBetaResourceWithRawResponse(client.beta) + self._client = client + + @cached_property + def dummy(self) -> dummy.AsyncDummyResourceWithRawResponse: + from .resources.dummy import AsyncDummyResourceWithRawResponse + + return AsyncDummyResourceWithRawResponse(self._client.dummy) + + @cached_property + def custom_metadata_fields(self) -> custom_metadata_fields.AsyncCustomMetadataFieldsResourceWithRawResponse: + from .resources.custom_metadata_fields import AsyncCustomMetadataFieldsResourceWithRawResponse + + return AsyncCustomMetadataFieldsResourceWithRawResponse(self._client.custom_metadata_fields) + + @cached_property + def files(self) -> files.AsyncFilesResourceWithRawResponse: + from .resources.files import AsyncFilesResourceWithRawResponse + + return AsyncFilesResourceWithRawResponse(self._client.files) + + @cached_property + def assets(self) -> assets.AsyncAssetsResourceWithRawResponse: + from .resources.assets import AsyncAssetsResourceWithRawResponse + + return AsyncAssetsResourceWithRawResponse(self._client.assets) + + @cached_property + def cache(self) -> cache.AsyncCacheResourceWithRawResponse: + from .resources.cache import AsyncCacheResourceWithRawResponse + + return AsyncCacheResourceWithRawResponse(self._client.cache) + + @cached_property + def folders(self) -> folders.AsyncFoldersResourceWithRawResponse: + from .resources.folders import AsyncFoldersResourceWithRawResponse + + return AsyncFoldersResourceWithRawResponse(self._client.folders) + + @cached_property + def accounts(self) -> accounts.AsyncAccountsResourceWithRawResponse: + from .resources.accounts import AsyncAccountsResourceWithRawResponse + + return AsyncAccountsResourceWithRawResponse(self._client.accounts) + + @cached_property + def beta(self) -> beta.AsyncBetaResourceWithRawResponse: + from .resources.beta import AsyncBetaResourceWithRawResponse + + return AsyncBetaResourceWithRawResponse(self._client.beta) class ImageKitWithStreamedResponse: + _client: ImageKit + def __init__(self, client: ImageKit) -> None: - self.dummy = dummy.DummyResourceWithStreamingResponse(client.dummy) - self.custom_metadata_fields = custom_metadata_fields.CustomMetadataFieldsResourceWithStreamingResponse( - client.custom_metadata_fields - ) - self.files = files.FilesResourceWithStreamingResponse(client.files) - self.assets = assets.AssetsResourceWithStreamingResponse(client.assets) - self.cache = cache.CacheResourceWithStreamingResponse(client.cache) - self.folders = folders.FoldersResourceWithStreamingResponse(client.folders) - self.accounts = accounts.AccountsResourceWithStreamingResponse(client.accounts) - self.beta = beta.BetaResourceWithStreamingResponse(client.beta) + self._client = client + + @cached_property + def dummy(self) -> dummy.DummyResourceWithStreamingResponse: + from .resources.dummy import DummyResourceWithStreamingResponse + + return DummyResourceWithStreamingResponse(self._client.dummy) + + @cached_property + def custom_metadata_fields(self) -> custom_metadata_fields.CustomMetadataFieldsResourceWithStreamingResponse: + from .resources.custom_metadata_fields import CustomMetadataFieldsResourceWithStreamingResponse + + return CustomMetadataFieldsResourceWithStreamingResponse(self._client.custom_metadata_fields) + + @cached_property + def files(self) -> files.FilesResourceWithStreamingResponse: + from .resources.files import FilesResourceWithStreamingResponse + + return FilesResourceWithStreamingResponse(self._client.files) + + @cached_property + def assets(self) -> assets.AssetsResourceWithStreamingResponse: + from .resources.assets import AssetsResourceWithStreamingResponse + + return AssetsResourceWithStreamingResponse(self._client.assets) + + @cached_property + def cache(self) -> cache.CacheResourceWithStreamingResponse: + from .resources.cache import CacheResourceWithStreamingResponse + + return CacheResourceWithStreamingResponse(self._client.cache) + + @cached_property + def folders(self) -> folders.FoldersResourceWithStreamingResponse: + from .resources.folders import FoldersResourceWithStreamingResponse + + return FoldersResourceWithStreamingResponse(self._client.folders) + + @cached_property + def accounts(self) -> accounts.AccountsResourceWithStreamingResponse: + from .resources.accounts import AccountsResourceWithStreamingResponse + + return AccountsResourceWithStreamingResponse(self._client.accounts) + + @cached_property + def beta(self) -> beta.BetaResourceWithStreamingResponse: + from .resources.beta import BetaResourceWithStreamingResponse + + return BetaResourceWithStreamingResponse(self._client.beta) class AsyncImageKitWithStreamedResponse: + _client: AsyncImageKit + def __init__(self, client: AsyncImageKit) -> None: - self.dummy = dummy.AsyncDummyResourceWithStreamingResponse(client.dummy) - self.custom_metadata_fields = custom_metadata_fields.AsyncCustomMetadataFieldsResourceWithStreamingResponse( - client.custom_metadata_fields - ) - self.files = files.AsyncFilesResourceWithStreamingResponse(client.files) - self.assets = assets.AsyncAssetsResourceWithStreamingResponse(client.assets) - self.cache = cache.AsyncCacheResourceWithStreamingResponse(client.cache) - self.folders = folders.AsyncFoldersResourceWithStreamingResponse(client.folders) - self.accounts = accounts.AsyncAccountsResourceWithStreamingResponse(client.accounts) - self.beta = beta.AsyncBetaResourceWithStreamingResponse(client.beta) + self._client = client + + @cached_property + def dummy(self) -> dummy.AsyncDummyResourceWithStreamingResponse: + from .resources.dummy import AsyncDummyResourceWithStreamingResponse + + return AsyncDummyResourceWithStreamingResponse(self._client.dummy) + + @cached_property + def custom_metadata_fields(self) -> custom_metadata_fields.AsyncCustomMetadataFieldsResourceWithStreamingResponse: + from .resources.custom_metadata_fields import AsyncCustomMetadataFieldsResourceWithStreamingResponse + + return AsyncCustomMetadataFieldsResourceWithStreamingResponse(self._client.custom_metadata_fields) + + @cached_property + def files(self) -> files.AsyncFilesResourceWithStreamingResponse: + from .resources.files import AsyncFilesResourceWithStreamingResponse + + return AsyncFilesResourceWithStreamingResponse(self._client.files) + + @cached_property + def assets(self) -> assets.AsyncAssetsResourceWithStreamingResponse: + from .resources.assets import AsyncAssetsResourceWithStreamingResponse + + return AsyncAssetsResourceWithStreamingResponse(self._client.assets) + + @cached_property + def cache(self) -> cache.AsyncCacheResourceWithStreamingResponse: + from .resources.cache import AsyncCacheResourceWithStreamingResponse + + return AsyncCacheResourceWithStreamingResponse(self._client.cache) + + @cached_property + def folders(self) -> folders.AsyncFoldersResourceWithStreamingResponse: + from .resources.folders import AsyncFoldersResourceWithStreamingResponse + + return AsyncFoldersResourceWithStreamingResponse(self._client.folders) + + @cached_property + def accounts(self) -> accounts.AsyncAccountsResourceWithStreamingResponse: + from .resources.accounts import AsyncAccountsResourceWithStreamingResponse + + return AsyncAccountsResourceWithStreamingResponse(self._client.accounts) + + @cached_property + def beta(self) -> beta.AsyncBetaResourceWithStreamingResponse: + from .resources.beta import AsyncBetaResourceWithStreamingResponse + + return AsyncBetaResourceWithStreamingResponse(self._client.beta) Client = ImageKit diff --git a/src/imagekitio/_models.py b/src/imagekitio/_models.py index ca9500b..29070e0 100644 --- a/src/imagekitio/_models.py +++ b/src/imagekitio/_models.py @@ -3,7 +3,20 @@ import os import inspect import weakref -from typing import TYPE_CHECKING, Any, Type, Union, Generic, TypeVar, Callable, Optional, cast +from typing import ( + IO, + TYPE_CHECKING, + Any, + Type, + Union, + Generic, + TypeVar, + Callable, + Iterable, + Optional, + AsyncIterable, + cast, +) from datetime import date, datetime from typing_extensions import ( List, @@ -787,6 +800,7 @@ class FinalRequestOptionsInput(TypedDict, total=False): timeout: float | Timeout | None files: HttpxRequestFiles | None idempotency_key: str + content: Union[bytes, bytearray, IO[bytes], Iterable[bytes], AsyncIterable[bytes], None] json_data: Body extra_json: AnyMapping follow_redirects: bool @@ -805,6 +819,7 @@ class FinalRequestOptions(pydantic.BaseModel): post_parser: Union[Callable[[Any], Any], NotGiven] = NotGiven() follow_redirects: Union[bool, None] = None + content: Union[bytes, bytearray, IO[bytes], Iterable[bytes], AsyncIterable[bytes], None] = None # It should be noted that we cannot use `json` here as that would override # a BaseModel method in an incompatible fashion. json_data: Union[Body, None] = None diff --git a/src/imagekitio/_types.py b/src/imagekitio/_types.py index 714fee2..eb6e4cf 100644 --- a/src/imagekitio/_types.py +++ b/src/imagekitio/_types.py @@ -13,9 +13,11 @@ Mapping, TypeVar, Callable, + Iterable, Iterator, Optional, Sequence, + AsyncIterable, ) from typing_extensions import ( Set, @@ -56,6 +58,13 @@ else: Base64FileInput = Union[IO[bytes], PathLike] FileContent = Union[IO[bytes], bytes, PathLike] # PathLike is not subscriptable in Python 3.8. + + +# Used for sending raw binary data / streaming data in request bodies +# e.g. for file uploads without multipart encoding +BinaryTypes = Union[bytes, bytearray, IO[bytes], Iterable[bytes]] +AsyncBinaryTypes = Union[bytes, bytearray, IO[bytes], AsyncIterable[bytes]] + FileTypes = Union[ # file (or bytes) FileContent, diff --git a/tests/test_client.py b/tests/test_client.py index 73532a8..8fef2fd 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -8,10 +8,11 @@ import json import asyncio import inspect +import dataclasses import tracemalloc -from typing import Any, Union, cast +from typing import Any, Union, TypeVar, Callable, Iterable, Iterator, Optional, Coroutine, cast from unittest import mock -from typing_extensions import Literal +from typing_extensions import Literal, AsyncIterator, override import httpx import pytest @@ -36,6 +37,7 @@ from .utils import update_env +T = TypeVar("T") base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") private_key = "My Private Key" password = "My Password" @@ -51,6 +53,57 @@ def _low_retry_timeout(*_args: Any, **_kwargs: Any) -> float: return 0.1 +def mirror_request_content(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, content=request.content) + + +# note: we can't use the httpx.MockTransport class as it consumes the request +# body itself, which means we can't test that the body is read lazily +class MockTransport(httpx.BaseTransport, httpx.AsyncBaseTransport): + def __init__( + self, + handler: Callable[[httpx.Request], httpx.Response] + | Callable[[httpx.Request], Coroutine[Any, Any, httpx.Response]], + ) -> None: + self.handler = handler + + @override + def handle_request( + self, + request: httpx.Request, + ) -> httpx.Response: + assert not inspect.iscoroutinefunction(self.handler), "handler must not be a coroutine function" + assert inspect.isfunction(self.handler), "handler must be a function" + return self.handler(request) + + @override + async def handle_async_request( + self, + request: httpx.Request, + ) -> httpx.Response: + assert inspect.iscoroutinefunction(self.handler), "handler must be a coroutine function" + return await self.handler(request) + + +@dataclasses.dataclass +class Counter: + value: int = 0 + + +def _make_sync_iterator(iterable: Iterable[T], counter: Optional[Counter] = None) -> Iterator[T]: + for item in iterable: + if counter: + counter.value += 1 + yield item + + +async def _make_async_iterator(iterable: Iterable[T], counter: Optional[Counter] = None) -> AsyncIterator[T]: + for item in iterable: + if counter: + counter.value += 1 + yield item + + def _get_open_connections(client: ImageKit | AsyncImageKit) -> int: transport = client._client._transport assert isinstance(transport, httpx.HTTPTransport) or isinstance(transport, httpx.AsyncHTTPTransport) @@ -548,6 +601,71 @@ def test_multipart_repeating_array(self, client: ImageKit) -> None: b"", ] + @pytest.mark.respx(base_url=base_url) + def test_binary_content_upload(self, respx_mock: MockRouter, client: ImageKit) -> None: + respx_mock.post("/upload").mock(side_effect=mirror_request_content) + + file_content = b"Hello, this is a test file." + + response = client.post( + "/upload", + content=file_content, + cast_to=httpx.Response, + options={"headers": {"Content-Type": "application/octet-stream"}}, + ) + + assert response.status_code == 200 + assert response.request.headers["Content-Type"] == "application/octet-stream" + assert response.content == file_content + + def test_binary_content_upload_with_iterator(self) -> None: + file_content = b"Hello, this is a test file." + counter = Counter() + iterator = _make_sync_iterator([file_content], counter=counter) + + def mock_handler(request: httpx.Request) -> httpx.Response: + assert counter.value == 0, "the request body should not have been read" + return httpx.Response(200, content=request.read()) + + with ImageKit( + base_url=base_url, + private_key=private_key, + password=password, + _strict_response_validation=True, + http_client=httpx.Client(transport=MockTransport(handler=mock_handler)), + ) as client: + response = client.post( + "/upload", + content=iterator, + cast_to=httpx.Response, + options={"headers": {"Content-Type": "application/octet-stream"}}, + ) + + assert response.status_code == 200 + assert response.request.headers["Content-Type"] == "application/octet-stream" + assert response.content == file_content + assert counter.value == 1 + + @pytest.mark.respx(base_url=base_url) + def test_binary_content_upload_with_body_is_deprecated(self, respx_mock: MockRouter, client: ImageKit) -> None: + respx_mock.post("/upload").mock(side_effect=mirror_request_content) + + file_content = b"Hello, this is a test file." + + with pytest.deprecated_call( + match="Passing raw bytes as `body` is deprecated and will be removed in a future version. Please pass raw bytes via the `content` parameter instead." + ): + response = client.post( + "/upload", + body=file_content, + cast_to=httpx.Response, + options={"headers": {"Content-Type": "application/octet-stream"}}, + ) + + assert response.status_code == 200 + assert response.request.headers["Content-Type"] == "application/octet-stream" + assert response.content == file_content + @pytest.mark.respx(base_url=base_url) def test_basic_union_response(self, respx_mock: MockRouter, client: ImageKit) -> None: class Model1(BaseModel): @@ -1455,6 +1573,73 @@ def test_multipart_repeating_array(self, async_client: AsyncImageKit) -> None: b"", ] + @pytest.mark.respx(base_url=base_url) + async def test_binary_content_upload(self, respx_mock: MockRouter, async_client: AsyncImageKit) -> None: + respx_mock.post("/upload").mock(side_effect=mirror_request_content) + + file_content = b"Hello, this is a test file." + + response = await async_client.post( + "/upload", + content=file_content, + cast_to=httpx.Response, + options={"headers": {"Content-Type": "application/octet-stream"}}, + ) + + assert response.status_code == 200 + assert response.request.headers["Content-Type"] == "application/octet-stream" + assert response.content == file_content + + async def test_binary_content_upload_with_asynciterator(self) -> None: + file_content = b"Hello, this is a test file." + counter = Counter() + iterator = _make_async_iterator([file_content], counter=counter) + + async def mock_handler(request: httpx.Request) -> httpx.Response: + assert counter.value == 0, "the request body should not have been read" + return httpx.Response(200, content=await request.aread()) + + async with AsyncImageKit( + base_url=base_url, + private_key=private_key, + password=password, + _strict_response_validation=True, + http_client=httpx.AsyncClient(transport=MockTransport(handler=mock_handler)), + ) as client: + response = await client.post( + "/upload", + content=iterator, + cast_to=httpx.Response, + options={"headers": {"Content-Type": "application/octet-stream"}}, + ) + + assert response.status_code == 200 + assert response.request.headers["Content-Type"] == "application/octet-stream" + assert response.content == file_content + assert counter.value == 1 + + @pytest.mark.respx(base_url=base_url) + async def test_binary_content_upload_with_body_is_deprecated( + self, respx_mock: MockRouter, async_client: AsyncImageKit + ) -> None: + respx_mock.post("/upload").mock(side_effect=mirror_request_content) + + file_content = b"Hello, this is a test file." + + with pytest.deprecated_call( + match="Passing raw bytes as `body` is deprecated and will be removed in a future version. Please pass raw bytes via the `content` parameter instead." + ): + response = await async_client.post( + "/upload", + body=file_content, + cast_to=httpx.Response, + options={"headers": {"Content-Type": "application/octet-stream"}}, + ) + + assert response.status_code == 200 + assert response.request.headers["Content-Type"] == "application/octet-stream" + assert response.content == file_content + @pytest.mark.respx(base_url=base_url) async def test_basic_union_response(self, respx_mock: MockRouter, async_client: AsyncImageKit) -> None: class Model1(BaseModel):