diff options
Diffstat (limited to '.venv/lib/python3.12/site-packages/storage3')
14 files changed, 1725 insertions, 0 deletions
diff --git a/.venv/lib/python3.12/site-packages/storage3/__init__.py b/.venv/lib/python3.12/site-packages/storage3/__init__.py new file mode 100644 index 00000000..48b4cb74 --- /dev/null +++ b/.venv/lib/python3.12/site-packages/storage3/__init__.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from typing import Literal, Union, overload + +from storage3._async import AsyncStorageClient +from storage3._async.bucket import AsyncStorageBucketAPI +from storage3._async.file_api import AsyncBucket +from storage3._sync import SyncStorageClient +from storage3._sync.bucket import SyncStorageBucketAPI +from storage3._sync.file_api import SyncBucket +from storage3.constants import DEFAULT_TIMEOUT +from storage3.version import __version__ + +__all__ = [ + "create_client", + "__version__", + "AsyncStorageClient", + "AsyncBucket", + "AsyncStorageBucketAPI", + "SyncStorageClient", + "SyncBucket", + "SyncStorageBucketAPI", +] + + +@overload +def create_client( + url: str, headers: dict[str, str], *, is_async: Literal[True] +) -> AsyncStorageClient: ... + + +@overload +def create_client( + url: str, headers: dict[str, str], *, is_async: Literal[False] +) -> SyncStorageClient: ... + + +def create_client( + url: str, headers: dict[str, str], *, is_async: bool, timeout: int = DEFAULT_TIMEOUT +) -> Union[AsyncStorageClient, SyncStorageClient]: + if is_async: + return AsyncStorageClient(url, headers, timeout) + else: + return SyncStorageClient(url, headers, timeout) diff --git a/.venv/lib/python3.12/site-packages/storage3/_async/__init__.py b/.venv/lib/python3.12/site-packages/storage3/_async/__init__.py new file mode 100644 index 00000000..694f552f --- /dev/null +++ b/.venv/lib/python3.12/site-packages/storage3/_async/__init__.py @@ -0,0 +1 @@ +from .client import AsyncStorageClient as AsyncStorageClient diff --git a/.venv/lib/python3.12/site-packages/storage3/_async/bucket.py b/.venv/lib/python3.12/site-packages/storage3/_async/bucket.py new file mode 100644 index 00000000..9d6bf886 --- /dev/null +++ b/.venv/lib/python3.12/site-packages/storage3/_async/bucket.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +from typing import Any, Optional + +from httpx import HTTPStatusError, Response + +from ..exceptions import StorageApiError +from ..types import CreateOrUpdateBucketOptions, RequestMethod +from ..utils import AsyncClient +from .file_api import AsyncBucket + +__all__ = ["AsyncStorageBucketAPI"] + + +class AsyncStorageBucketAPI: + """This class abstracts access to the endpoint to the Get, List, Empty, and Delete operations on a bucket""" + + def __init__(self, session: AsyncClient) -> None: + self._client = session + + async def _request( + self, + method: RequestMethod, + url: str, + json: Optional[dict[Any, Any]] = None, + ) -> Response: + try: + response = await self._client.request(method, url, json=json) + response.raise_for_status() + except HTTPStatusError as exc: + resp = exc.response.json() + raise StorageApiError(resp["message"], resp["error"], resp["statusCode"]) + + return response + + async def list_buckets(self) -> list[AsyncBucket]: + """Retrieves the details of all storage buckets within an existing product.""" + # if the request doesn't error, it is assured to return a list + res = await self._request("GET", "/bucket") + return [AsyncBucket(**bucket) for bucket in res.json()] + + async def get_bucket(self, id: str) -> AsyncBucket: + """Retrieves the details of an existing storage bucket. + + Parameters + ---------- + id + The unique identifier of the bucket you would like to retrieve. + """ + res = await self._request("GET", f"/bucket/{id}") + json = res.json() + return AsyncBucket(**json) + + async def create_bucket( + self, + id: str, + name: Optional[str] = None, + options: Optional[CreateOrUpdateBucketOptions] = None, + ) -> dict[str, str]: + """Creates a new storage bucket. + + Parameters + ---------- + id + A unique identifier for the bucket you are creating. + name + A name for the bucket you are creating. If not passed, the id is used as the name as well. + options + Extra options to send while creating the bucket. Valid options are `public`, `file_size_limit` and + `allowed_mime_types`. + """ + json: dict[str, Any] = {"id": id, "name": name or id} + if options: + json.update(**options) + res = await self._request( + "POST", + "/bucket", + json=json, + ) + return res.json() + + async def update_bucket( + self, id: str, options: CreateOrUpdateBucketOptions + ) -> dict[str, str]: + """Update a storage bucket. + + Parameters + ---------- + id + The unique identifier of the bucket you would like to update. + options + The properties you want to update. Valid options are `public`, `file_size_limit` and + `allowed_mime_types`. + """ + json = {"id": id, "name": id, **options} + res = await self._request("PUT", f"/bucket/{id}", json=json) + return res.json() + + async def empty_bucket(self, id: str) -> dict[str, str]: + """Removes all objects inside a single bucket. + + Parameters + ---------- + id + The unique identifier of the bucket you would like to empty. + """ + res = await self._request("POST", f"/bucket/{id}/empty", json={}) + return res.json() + + async def delete_bucket(self, id: str) -> dict[str, str]: + """Deletes an existing bucket. Note that you cannot delete buckets with existing objects inside. You must first + `empty()` the bucket. + + Parameters + ---------- + id + The unique identifier of the bucket you would like to delete. + """ + res = await self._request("DELETE", f"/bucket/{id}", json={}) + return res.json() diff --git a/.venv/lib/python3.12/site-packages/storage3/_async/client.py b/.venv/lib/python3.12/site-packages/storage3/_async/client.py new file mode 100644 index 00000000..95852a02 --- /dev/null +++ b/.venv/lib/python3.12/site-packages/storage3/_async/client.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +from typing import Optional + +from storage3.constants import DEFAULT_TIMEOUT + +from ..utils import AsyncClient +from ..version import __version__ +from .bucket import AsyncStorageBucketAPI +from .file_api import AsyncBucketProxy + +__all__ = [ + "AsyncStorageClient", +] + + +class AsyncStorageClient(AsyncStorageBucketAPI): + """Manage storage buckets and files.""" + + def __init__( + self, + url: str, + headers: dict[str, str], + timeout: int = DEFAULT_TIMEOUT, + verify: bool = True, + proxy: Optional[str] = None, + ) -> None: + headers = { + "User-Agent": f"supabase-py/storage3 v{__version__}", + **headers, + } + self.session = self._create_session(url, headers, timeout, verify, proxy) + super().__init__(self.session) + + def _create_session( + self, + base_url: str, + headers: dict[str, str], + timeout: int, + verify: bool = True, + proxy: Optional[str] = None, + ) -> AsyncClient: + return AsyncClient( + base_url=base_url, + headers=headers, + timeout=timeout, + proxy=proxy, + verify=bool(verify), + follow_redirects=True, + http2=True, + ) + + async def __aenter__(self) -> AsyncStorageClient: + return self + + async def __aexit__(self, exc_type, exc, tb) -> None: + await self.aclose() + + async def aclose(self) -> None: + await self.session.aclose() + + def from_(self, id: str) -> AsyncBucketProxy: + """Run a storage file operation. + + Parameters + ---------- + id + The unique identifier of the bucket + """ + return AsyncBucketProxy(id, self._client) diff --git a/.venv/lib/python3.12/site-packages/storage3/_async/file_api.py b/.venv/lib/python3.12/site-packages/storage3/_async/file_api.py new file mode 100644 index 00000000..029a0330 --- /dev/null +++ b/.venv/lib/python3.12/site-packages/storage3/_async/file_api.py @@ -0,0 +1,559 @@ +from __future__ import annotations + +import base64 +import json +import urllib.parse +from dataclasses import dataclass, field +from io import BufferedReader, FileIO +from pathlib import Path +from typing import Any, Literal, Optional, Union, cast + +from httpx import HTTPStatusError, Response + +from ..constants import DEFAULT_FILE_OPTIONS, DEFAULT_SEARCH_OPTIONS +from ..exceptions import StorageApiError +from ..types import ( + BaseBucket, + CreateSignedUrlResponse, + CreateSignedURLsOptions, + DownloadOptions, + FileOptions, + ListBucketFilesOptions, + RequestMethod, + SignedUploadURL, + SignedUrlResponse, + UploadData, + UploadResponse, + URLOptions, +) +from ..utils import AsyncClient, StorageException + +__all__ = ["AsyncBucket"] + + +class AsyncBucketActionsMixin: + """Functions needed to access the file API.""" + + id: str + _client: AsyncClient + + async def _request( + self, + method: RequestMethod, + url: str, + headers: Optional[dict[str, Any]] = None, + json: Optional[dict[Any, Any]] = None, + files: Optional[Any] = None, + **kwargs: Any, + ) -> Response: + try: + response = await self._client.request( + method, url, headers=headers or {}, json=json, files=files, **kwargs + ) + response.raise_for_status() + except HTTPStatusError as exc: + resp = exc.response.json() + raise StorageApiError(resp["message"], resp["error"], resp["statusCode"]) + + # close the resource before returning the response + if files and "file" in files and isinstance(files["file"][1], BufferedReader): + files["file"][1].close() + + return response + + async def create_signed_upload_url(self, path: str) -> SignedUploadURL: + """ + Creates a signed upload URL. + + Parameters + ---------- + path + The file path, including the file name. For example `folder/image.png`. + """ + _path = self._get_final_path(path) + response = await self._request("POST", f"/object/upload/sign/{_path}") + data = response.json() + full_url: urllib.parse.ParseResult = urllib.parse.urlparse( + str(self._client.base_url) + cast(str, data["url"]).lstrip("/") + ) + query_params = urllib.parse.parse_qs(full_url.query) + if not query_params.get("token"): + raise StorageException("No token sent by the API") + return { + "signed_url": full_url.geturl(), + "signedUrl": full_url.geturl(), + "token": query_params["token"][0], + "path": path, + } + + async def upload_to_signed_url( + self, + path: str, + token: str, + file: Union[BufferedReader, bytes, FileIO, str, Path], + file_options: Optional[FileOptions] = None, + ) -> UploadResponse: + """ + Upload a file with a token generated from :meth:`.create_signed_url` + + Parameters + ---------- + path + The file path, including the file name + token + The token generated from :meth:`.create_signed_url` + file + The file contents or a file-like object to upload + file_options + Additional options for the uploaded file + """ + _path = self._get_final_path(path) + _url = urllib.parse.urlparse(f"/object/upload/sign/{_path}") + query_params = urllib.parse.urlencode({"token": token}) + final_url = f"{_url.geturl()}?{query_params}" + + if file_options is None: + file_options = {} + + cache_control = file_options.get("cache-control") + # cacheControl is also passed as form data + # https://github.com/supabase/storage-js/blob/fa44be8156295ba6320ffeff96bdf91016536a46/src/packages/StorageFileApi.ts#L89 + _data = {} + if cache_control: + file_options["cache-control"] = f"max-age={cache_control}" + _data = {"cacheControl": cache_control} + headers = { + **self._client.headers, + **DEFAULT_FILE_OPTIONS, + **file_options, + } + filename = path.rsplit("/", maxsplit=1)[-1] + + if ( + isinstance(file, BufferedReader) + or isinstance(file, bytes) + or isinstance(file, FileIO) + ): + # bytes or byte-stream-like object received + _file = {"file": (filename, file, headers.pop("content-type"))} + else: + # str or pathlib.path received + _file = { + "file": ( + filename, + open(file, "rb"), + headers.pop("content-type"), + ) + } + response = await self._request( + "PUT", final_url, files=_file, headers=headers, data=_data + ) + data: UploadData = response.json() + + return UploadResponse(path=path, Key=data.get("Key")) + + async def create_signed_url( + self, path: str, expires_in: int, options: URLOptions = {} + ) -> SignedUrlResponse: + """ + Parameters + ---------- + path + file path to be downloaded, including the current file name. + expires_in + number of seconds until the signed URL expires. + options + options to be passed for downloading or transforming the file. + """ + json = {"expiresIn": str(expires_in)} + download_query = "" + if options.get("download"): + json.update({"download": options["download"]}) + + download_query = ( + "&download=" + if options.get("download") is True + else f"&download={options.get('download')}" + ) + if options.get("transform"): + json.update({"transform": options["transform"]}) + + path = self._get_final_path(path) + response = await self._request( + "POST", + f"/object/sign/{path}", + json=json, + ) + data = response.json() + + # Prepare URL + url = urllib.parse.urlparse(data["signedURL"]) + url = urllib.parse.quote(url.path) + f"?{url.query}" + + signedURL = ( + f"{self._client.base_url}{cast(str, url).lstrip('/')}{download_query}" + ) + data: SignedUrlResponse = {"signedURL": signedURL, "signedUrl": signedURL} + return data + + async def create_signed_urls( + self, paths: list[str], expires_in: int, options: CreateSignedURLsOptions = {} + ) -> list[CreateSignedUrlResponse]: + """ + Parameters + ---------- + path + file path to be downloaded, including the current file name. + expires_in + number of seconds until the signed URL expires. + options + options to be passed for downloading the file. + """ + json = {"paths": paths, "expiresIn": str(expires_in)} + download_query = "" + if options.get("download"): + json.update({"download": options.get("download")}) + + download_query = ( + "&download=" + if options.get("download") is True + else f"&download={options.get('download')}" + ) + + response = await self._request( + "POST", + f"/object/sign/{self.id}", + json=json, + ) + data = response.json() + signed_urls = [] + for item in data: + + # Prepare URL + url = urllib.parse.urlparse(item["signedURL"]) + url = urllib.parse.quote(url.path) + f"?{url.query}" + + signedURL = ( + f"{self._client.base_url}{cast(str, url).lstrip('/')}{download_query}" + ) + signed_item: CreateSignedUrlResponse = { + "error": item["error"], + "path": item["path"], + "signedURL": signedURL, + "signedUrl": signedURL, + } + signed_urls.append(signed_item) + return signed_urls + + async def get_public_url(self, path: str, options: URLOptions = {}) -> str: + """ + Parameters + ---------- + path + file path, including the path and file name. For example `folder/image.png`. + """ + _query_string = [] + download_query = "" + if options.get("download"): + download_query = ( + "&download=" + if options.get("download") is True + else f"&download={options.get('download')}" + ) + + if download_query: + _query_string.append(download_query) + + render_path = "render/image" if options.get("transform") else "object" + transformation_query = ( + urllib.parse.urlencode(options.get("transform")) + if options.get("transform") + else None + ) + + if transformation_query: + _query_string.append(transformation_query) + + query_string = "&".join(_query_string) + query_string = f"?{query_string}" + _path = self._get_final_path(path) + return f"{self._client.base_url}{render_path}/public/{_path}{query_string}" + + async def move(self, from_path: str, to_path: str) -> dict[str, str]: + """ + Moves an existing file, optionally renaming it at the same time. + + Parameters + ---------- + from_path + The original file path, including the current file name. For example `folder/image.png`. + to_path + The new file path, including the new file name. For example `folder/image-copy.png`. + """ + res = await self._request( + "POST", + "/object/move", + json={ + "bucketId": self.id, + "sourceKey": from_path, + "destinationKey": to_path, + }, + ) + return res.json() + + async def copy(self, from_path: str, to_path: str) -> dict[str, str]: + """ + Copies an existing file to a new path in the same bucket. + + Parameters + ---------- + from_path + The original file path, including the current file name. For example `folder/image.png`. + to_path + The new file path, including the new file name. For example `folder/image-copy.png`. + """ + res = await self._request( + "POST", + "/object/copy", + json={ + "bucketId": self.id, + "sourceKey": from_path, + "destinationKey": to_path, + }, + ) + return res.json() + + async def remove(self, paths: list) -> list[dict[str, Any]]: + """ + Deletes files within the same bucket + + Parameters + ---------- + paths + An array or list of files to be deletes, including the path and file name. For example [`folder/image.png`]. + """ + response = await self._request( + "DELETE", + f"/object/{self.id}", + json={"prefixes": paths}, + ) + return response.json() + + async def info( + self, + path: str, + ) -> list[dict[str, str]]: + """ + Lists info for a particular file. + + Parameters + ---------- + path + The path to the file. + """ + response = await self._request( + "GET", + f"/object/info/{self.id}/{path}", + ) + return response.json() + + async def exists( + self, + path: str, + ) -> bool: + """ + Returns True if the file exists, False otherwise. + + Parameters + ---------- + path + The path to the file. + """ + try: + response = await self._request( + "HEAD", + f"/object/{self.id}/{path}", + ) + return response.status_code == 200 + except json.JSONDecodeError: + return False + + async def list( + self, + path: Optional[str] = None, + options: Optional[ListBucketFilesOptions] = None, + ) -> list[dict[str, str]]: + """ + Lists all the files within a bucket. + + Parameters + ---------- + path + The folder path. + options + Search options, including `limit`, `offset`, `sortBy` and `search`. + """ + extra_options = options or {} + extra_headers = {"Content-Type": "application/json"} + body = { + **DEFAULT_SEARCH_OPTIONS, + **extra_options, + "prefix": path or "", + } + response = await self._request( + "POST", + f"/object/list/{self.id}", + json=body, + headers=extra_headers, + ) + return response.json() + + async def download(self, path: str, options: DownloadOptions = {}) -> bytes: + """ + Downloads a file. + + Parameters + ---------- + path + The file path to be downloaded, including the path and file name. For example `folder/image.png`. + """ + render_path = ( + "render/image/authenticated" if options.get("transform") else "object" + ) + transformation_query = urllib.parse.urlencode(options.get("transform") or {}) + query_string = f"?{transformation_query}" if transformation_query else "" + + _path = self._get_final_path(path) + response = await self._request( + "GET", + f"{render_path}/{_path}{query_string}", + ) + return response.content + + async def _upload_or_update( + self, + method: Literal["POST", "PUT"], + path: str, + file: Union[BufferedReader, bytes, FileIO, str, Path], + file_options: Optional[FileOptions] = None, + ) -> UploadResponse: + """ + Uploads a file to an existing bucket. + + Parameters + ---------- + path + The relative file path including the bucket ID. Should be of the format `bucket/folder/subfolder/filename.png`. + The bucket must already exist before attempting to upload. + file + The File object to be stored in the bucket. or a async generator of chunks + file_options + HTTP headers. + """ + if file_options is None: + file_options = {} + cache_control = file_options.pop("cache-control", None) + _data = {} + + upsert = file_options.pop("upsert", None) + if upsert: + file_options.update({"x-upsert": upsert}) + + metadata = file_options.pop("metadata", None) + file_opts_headers = file_options.pop("headers", None) + + headers = { + **self._client.headers, + **DEFAULT_FILE_OPTIONS, + **file_options, + } + + if metadata: + metadata_str = json.dumps(metadata) + headers["x-metadata"] = base64.b64encode(metadata_str.encode()) + _data.update({"metadata": metadata_str}) + + if file_opts_headers: + headers.update({**file_opts_headers}) + + # Only include x-upsert on a POST method + if method != "POST": + del headers["x-upsert"] + + filename = path.rsplit("/", maxsplit=1)[-1] + + if cache_control: + headers["cache-control"] = f"max-age={cache_control}" + _data.update({"cacheControl": cache_control}) + + if ( + isinstance(file, BufferedReader) + or isinstance(file, bytes) + or isinstance(file, FileIO) + ): + # bytes or byte-stream-like object received + files = {"file": (filename, file, headers.pop("content-type"))} + else: + # str or pathlib.path received + files = { + "file": ( + filename, + open(file, "rb"), + headers.pop("content-type"), + ) + } + + _path = self._get_final_path(path) + + response = await self._request( + method, f"/object/{_path}", files=files, headers=headers, data=_data + ) + + data: UploadData = response.json() + + return UploadResponse(path=path, Key=data.get("Key")) + + async def upload( + self, + path: str, + file: Union[BufferedReader, bytes, FileIO, str, Path], + file_options: Optional[FileOptions] = None, + ) -> UploadResponse: + """ + Uploads a file to an existing bucket. + + Parameters + ---------- + path + The relative file path including the bucket ID. Should be of the format `bucket/folder/subfolder/filename.png`. + The bucket must already exist before attempting to upload. + file + The File object to be stored in the bucket. or a async generator of chunks + file_options + HTTP headers. + """ + return await self._upload_or_update("POST", path, file, file_options) + + async def update( + self, + path: str, + file: Union[BufferedReader, bytes, FileIO, str, Path], + file_options: Optional[FileOptions] = None, + ) -> UploadResponse: + return await self._upload_or_update("PUT", path, file, file_options) + + def _get_final_path(self, path: str) -> str: + return f"{self.id}/{path}" + + +@dataclass(repr=False) +class AsyncBucket(BaseBucket): + """Represents a storage bucket.""" + + +@dataclass +class AsyncBucketProxy(AsyncBucketActionsMixin): + """A bucket proxy, this contains the minimum required fields to query the File API.""" + + id: str + _client: AsyncClient = field(repr=False) diff --git a/.venv/lib/python3.12/site-packages/storage3/_sync/__init__.py b/.venv/lib/python3.12/site-packages/storage3/_sync/__init__.py new file mode 100644 index 00000000..9eedb131 --- /dev/null +++ b/.venv/lib/python3.12/site-packages/storage3/_sync/__init__.py @@ -0,0 +1 @@ +from .client import SyncStorageClient as SyncStorageClient diff --git a/.venv/lib/python3.12/site-packages/storage3/_sync/bucket.py b/.venv/lib/python3.12/site-packages/storage3/_sync/bucket.py new file mode 100644 index 00000000..8700bf06 --- /dev/null +++ b/.venv/lib/python3.12/site-packages/storage3/_sync/bucket.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +from typing import Any, Optional + +from httpx import HTTPStatusError, Response + +from ..exceptions import StorageApiError +from ..types import CreateOrUpdateBucketOptions, RequestMethod +from ..utils import SyncClient +from .file_api import SyncBucket + +__all__ = ["SyncStorageBucketAPI"] + + +class SyncStorageBucketAPI: + """This class abstracts access to the endpoint to the Get, List, Empty, and Delete operations on a bucket""" + + def __init__(self, session: SyncClient) -> None: + self._client = session + + def _request( + self, + method: RequestMethod, + url: str, + json: Optional[dict[Any, Any]] = None, + ) -> Response: + try: + response = self._client.request(method, url, json=json) + response.raise_for_status() + except HTTPStatusError as exc: + resp = exc.response.json() + raise StorageApiError(resp["message"], resp["error"], resp["statusCode"]) + + return response + + def list_buckets(self) -> list[SyncBucket]: + """Retrieves the details of all storage buckets within an existing product.""" + # if the request doesn't error, it is assured to return a list + res = self._request("GET", "/bucket") + return [SyncBucket(**bucket) for bucket in res.json()] + + def get_bucket(self, id: str) -> SyncBucket: + """Retrieves the details of an existing storage bucket. + + Parameters + ---------- + id + The unique identifier of the bucket you would like to retrieve. + """ + res = self._request("GET", f"/bucket/{id}") + json = res.json() + return SyncBucket(**json) + + def create_bucket( + self, + id: str, + name: Optional[str] = None, + options: Optional[CreateOrUpdateBucketOptions] = None, + ) -> dict[str, str]: + """Creates a new storage bucket. + + Parameters + ---------- + id + A unique identifier for the bucket you are creating. + name + A name for the bucket you are creating. If not passed, the id is used as the name as well. + options + Extra options to send while creating the bucket. Valid options are `public`, `file_size_limit` and + `allowed_mime_types`. + """ + json: dict[str, Any] = {"id": id, "name": name or id} + if options: + json.update(**options) + res = self._request( + "POST", + "/bucket", + json=json, + ) + return res.json() + + def update_bucket( + self, id: str, options: CreateOrUpdateBucketOptions + ) -> dict[str, str]: + """Update a storage bucket. + + Parameters + ---------- + id + The unique identifier of the bucket you would like to update. + options + The properties you want to update. Valid options are `public`, `file_size_limit` and + `allowed_mime_types`. + """ + json = {"id": id, "name": id, **options} + res = self._request("PUT", f"/bucket/{id}", json=json) + return res.json() + + def empty_bucket(self, id: str) -> dict[str, str]: + """Removes all objects inside a single bucket. + + Parameters + ---------- + id + The unique identifier of the bucket you would like to empty. + """ + res = self._request("POST", f"/bucket/{id}/empty", json={}) + return res.json() + + def delete_bucket(self, id: str) -> dict[str, str]: + """Deletes an existing bucket. Note that you cannot delete buckets with existing objects inside. You must first + `empty()` the bucket. + + Parameters + ---------- + id + The unique identifier of the bucket you would like to delete. + """ + res = self._request("DELETE", f"/bucket/{id}", json={}) + return res.json() diff --git a/.venv/lib/python3.12/site-packages/storage3/_sync/client.py b/.venv/lib/python3.12/site-packages/storage3/_sync/client.py new file mode 100644 index 00000000..d2a2e299 --- /dev/null +++ b/.venv/lib/python3.12/site-packages/storage3/_sync/client.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +from typing import Optional + +from storage3.constants import DEFAULT_TIMEOUT + +from ..utils import SyncClient +from ..version import __version__ +from .bucket import SyncStorageBucketAPI +from .file_api import SyncBucketProxy + +__all__ = [ + "SyncStorageClient", +] + + +class SyncStorageClient(SyncStorageBucketAPI): + """Manage storage buckets and files.""" + + def __init__( + self, + url: str, + headers: dict[str, str], + timeout: int = DEFAULT_TIMEOUT, + verify: bool = True, + proxy: Optional[str] = None, + ) -> None: + headers = { + "User-Agent": f"supabase-py/storage3 v{__version__}", + **headers, + } + self.session = self._create_session(url, headers, timeout, verify, proxy) + super().__init__(self.session) + + def _create_session( + self, + base_url: str, + headers: dict[str, str], + timeout: int, + verify: bool = True, + proxy: Optional[str] = None, + ) -> SyncClient: + return SyncClient( + base_url=base_url, + headers=headers, + timeout=timeout, + proxy=proxy, + verify=bool(verify), + follow_redirects=True, + http2=True, + ) + + def __enter__(self) -> SyncStorageClient: + return self + + def __exit__(self, exc_type, exc, tb) -> None: + self.aclose() + + def aclose(self) -> None: + self.session.aclose() + + def from_(self, id: str) -> SyncBucketProxy: + """Run a storage file operation. + + Parameters + ---------- + id + The unique identifier of the bucket + """ + return SyncBucketProxy(id, self._client) diff --git a/.venv/lib/python3.12/site-packages/storage3/_sync/file_api.py b/.venv/lib/python3.12/site-packages/storage3/_sync/file_api.py new file mode 100644 index 00000000..5ee5e9f6 --- /dev/null +++ b/.venv/lib/python3.12/site-packages/storage3/_sync/file_api.py @@ -0,0 +1,559 @@ +from __future__ import annotations + +import base64 +import json +import urllib.parse +from dataclasses import dataclass, field +from io import BufferedReader, FileIO +from pathlib import Path +from typing import Any, Literal, Optional, Union, cast + +from httpx import HTTPStatusError, Response + +from ..constants import DEFAULT_FILE_OPTIONS, DEFAULT_SEARCH_OPTIONS +from ..exceptions import StorageApiError +from ..types import ( + BaseBucket, + CreateSignedUrlResponse, + CreateSignedURLsOptions, + DownloadOptions, + FileOptions, + ListBucketFilesOptions, + RequestMethod, + SignedUploadURL, + SignedUrlResponse, + UploadData, + UploadResponse, + URLOptions, +) +from ..utils import StorageException, SyncClient + +__all__ = ["SyncBucket"] + + +class SyncBucketActionsMixin: + """Functions needed to access the file API.""" + + id: str + _client: SyncClient + + def _request( + self, + method: RequestMethod, + url: str, + headers: Optional[dict[str, Any]] = None, + json: Optional[dict[Any, Any]] = None, + files: Optional[Any] = None, + **kwargs: Any, + ) -> Response: + try: + response = self._client.request( + method, url, headers=headers or {}, json=json, files=files, **kwargs + ) + response.raise_for_status() + except HTTPStatusError as exc: + resp = exc.response.json() + raise StorageApiError(resp["message"], resp["error"], resp["statusCode"]) + + # close the resource before returning the response + if files and "file" in files and isinstance(files["file"][1], BufferedReader): + files["file"][1].close() + + return response + + def create_signed_upload_url(self, path: str) -> SignedUploadURL: + """ + Creates a signed upload URL. + + Parameters + ---------- + path + The file path, including the file name. For example `folder/image.png`. + """ + _path = self._get_final_path(path) + response = self._request("POST", f"/object/upload/sign/{_path}") + data = response.json() + full_url: urllib.parse.ParseResult = urllib.parse.urlparse( + str(self._client.base_url) + cast(str, data["url"]).lstrip("/") + ) + query_params = urllib.parse.parse_qs(full_url.query) + if not query_params.get("token"): + raise StorageException("No token sent by the API") + return { + "signed_url": full_url.geturl(), + "signedUrl": full_url.geturl(), + "token": query_params["token"][0], + "path": path, + } + + def upload_to_signed_url( + self, + path: str, + token: str, + file: Union[BufferedReader, bytes, FileIO, str, Path], + file_options: Optional[FileOptions] = None, + ) -> UploadResponse: + """ + Upload a file with a token generated from :meth:`.create_signed_url` + + Parameters + ---------- + path + The file path, including the file name + token + The token generated from :meth:`.create_signed_url` + file + The file contents or a file-like object to upload + file_options + Additional options for the uploaded file + """ + _path = self._get_final_path(path) + _url = urllib.parse.urlparse(f"/object/upload/sign/{_path}") + query_params = urllib.parse.urlencode({"token": token}) + final_url = f"{_url.geturl()}?{query_params}" + + if file_options is None: + file_options = {} + + cache_control = file_options.get("cache-control") + # cacheControl is also passed as form data + # https://github.com/supabase/storage-js/blob/fa44be8156295ba6320ffeff96bdf91016536a46/src/packages/StorageFileApi.ts#L89 + _data = {} + if cache_control: + file_options["cache-control"] = f"max-age={cache_control}" + _data = {"cacheControl": cache_control} + headers = { + **self._client.headers, + **DEFAULT_FILE_OPTIONS, + **file_options, + } + filename = path.rsplit("/", maxsplit=1)[-1] + + if ( + isinstance(file, BufferedReader) + or isinstance(file, bytes) + or isinstance(file, FileIO) + ): + # bytes or byte-stream-like object received + _file = {"file": (filename, file, headers.pop("content-type"))} + else: + # str or pathlib.path received + _file = { + "file": ( + filename, + open(file, "rb"), + headers.pop("content-type"), + ) + } + response = self._request( + "PUT", final_url, files=_file, headers=headers, data=_data + ) + data: UploadData = response.json() + + return UploadResponse(path=path, Key=data.get("Key")) + + def create_signed_url( + self, path: str, expires_in: int, options: URLOptions = {} + ) -> SignedUrlResponse: + """ + Parameters + ---------- + path + file path to be downloaded, including the current file name. + expires_in + number of seconds until the signed URL expires. + options + options to be passed for downloading or transforming the file. + """ + json = {"expiresIn": str(expires_in)} + download_query = "" + if options.get("download"): + json.update({"download": options["download"]}) + + download_query = ( + "&download=" + if options.get("download") is True + else f"&download={options.get('download')}" + ) + if options.get("transform"): + json.update({"transform": options["transform"]}) + + path = self._get_final_path(path) + response = self._request( + "POST", + f"/object/sign/{path}", + json=json, + ) + data = response.json() + + # Prepare URL + url = urllib.parse.urlparse(data["signedURL"]) + url = urllib.parse.quote(url.path) + f"?{url.query}" + + signedURL = ( + f"{self._client.base_url}{cast(str, url).lstrip('/')}{download_query}" + ) + data: SignedUrlResponse = {"signedURL": signedURL, "signedUrl": signedURL} + return data + + def create_signed_urls( + self, paths: list[str], expires_in: int, options: CreateSignedURLsOptions = {} + ) -> list[CreateSignedUrlResponse]: + """ + Parameters + ---------- + path + file path to be downloaded, including the current file name. + expires_in + number of seconds until the signed URL expires. + options + options to be passed for downloading the file. + """ + json = {"paths": paths, "expiresIn": str(expires_in)} + download_query = "" + if options.get("download"): + json.update({"download": options.get("download")}) + + download_query = ( + "&download=" + if options.get("download") is True + else f"&download={options.get('download')}" + ) + + response = self._request( + "POST", + f"/object/sign/{self.id}", + json=json, + ) + data = response.json() + signed_urls = [] + for item in data: + + # Prepare URL + url = urllib.parse.urlparse(item["signedURL"]) + url = urllib.parse.quote(url.path) + f"?{url.query}" + + signedURL = ( + f"{self._client.base_url}{cast(str, url).lstrip('/')}{download_query}" + ) + signed_item: CreateSignedUrlResponse = { + "error": item["error"], + "path": item["path"], + "signedURL": signedURL, + "signedUrl": signedURL, + } + signed_urls.append(signed_item) + return signed_urls + + def get_public_url(self, path: str, options: URLOptions = {}) -> str: + """ + Parameters + ---------- + path + file path, including the path and file name. For example `folder/image.png`. + """ + _query_string = [] + download_query = "" + if options.get("download"): + download_query = ( + "&download=" + if options.get("download") is True + else f"&download={options.get('download')}" + ) + + if download_query: + _query_string.append(download_query) + + render_path = "render/image" if options.get("transform") else "object" + transformation_query = ( + urllib.parse.urlencode(options.get("transform")) + if options.get("transform") + else None + ) + + if transformation_query: + _query_string.append(transformation_query) + + query_string = "&".join(_query_string) + query_string = f"?{query_string}" + _path = self._get_final_path(path) + return f"{self._client.base_url}{render_path}/public/{_path}{query_string}" + + def move(self, from_path: str, to_path: str) -> dict[str, str]: + """ + Moves an existing file, optionally renaming it at the same time. + + Parameters + ---------- + from_path + The original file path, including the current file name. For example `folder/image.png`. + to_path + The new file path, including the new file name. For example `folder/image-copy.png`. + """ + res = self._request( + "POST", + "/object/move", + json={ + "bucketId": self.id, + "sourceKey": from_path, + "destinationKey": to_path, + }, + ) + return res.json() + + def copy(self, from_path: str, to_path: str) -> dict[str, str]: + """ + Copies an existing file to a new path in the same bucket. + + Parameters + ---------- + from_path + The original file path, including the current file name. For example `folder/image.png`. + to_path + The new file path, including the new file name. For example `folder/image-copy.png`. + """ + res = self._request( + "POST", + "/object/copy", + json={ + "bucketId": self.id, + "sourceKey": from_path, + "destinationKey": to_path, + }, + ) + return res.json() + + def remove(self, paths: list) -> list[dict[str, Any]]: + """ + Deletes files within the same bucket + + Parameters + ---------- + paths + An array or list of files to be deletes, including the path and file name. For example [`folder/image.png`]. + """ + response = self._request( + "DELETE", + f"/object/{self.id}", + json={"prefixes": paths}, + ) + return response.json() + + def info( + self, + path: str, + ) -> list[dict[str, str]]: + """ + Lists info for a particular file. + + Parameters + ---------- + path + The path to the file. + """ + response = self._request( + "GET", + f"/object/info/{self.id}/{path}", + ) + return response.json() + + def exists( + self, + path: str, + ) -> bool: + """ + Returns True if the file exists, False otherwise. + + Parameters + ---------- + path + The path to the file. + """ + try: + response = self._request( + "HEAD", + f"/object/{self.id}/{path}", + ) + return response.status_code == 200 + except json.JSONDecodeError: + return False + + def list( + self, + path: Optional[str] = None, + options: Optional[ListBucketFilesOptions] = None, + ) -> list[dict[str, str]]: + """ + Lists all the files within a bucket. + + Parameters + ---------- + path + The folder path. + options + Search options, including `limit`, `offset`, `sortBy` and `search`. + """ + extra_options = options or {} + extra_headers = {"Content-Type": "application/json"} + body = { + **DEFAULT_SEARCH_OPTIONS, + **extra_options, + "prefix": path or "", + } + response = self._request( + "POST", + f"/object/list/{self.id}", + json=body, + headers=extra_headers, + ) + return response.json() + + def download(self, path: str, options: DownloadOptions = {}) -> bytes: + """ + Downloads a file. + + Parameters + ---------- + path + The file path to be downloaded, including the path and file name. For example `folder/image.png`. + """ + render_path = ( + "render/image/authenticated" if options.get("transform") else "object" + ) + transformation_query = urllib.parse.urlencode(options.get("transform") or {}) + query_string = f"?{transformation_query}" if transformation_query else "" + + _path = self._get_final_path(path) + response = self._request( + "GET", + f"{render_path}/{_path}{query_string}", + ) + return response.content + + def _upload_or_update( + self, + method: Literal["POST", "PUT"], + path: str, + file: Union[BufferedReader, bytes, FileIO, str, Path], + file_options: Optional[FileOptions] = None, + ) -> UploadResponse: + """ + Uploads a file to an existing bucket. + + Parameters + ---------- + path + The relative file path including the bucket ID. Should be of the format `bucket/folder/subfolder/filename.png`. + The bucket must already exist before attempting to upload. + file + The File object to be stored in the bucket. or a async generator of chunks + file_options + HTTP headers. + """ + if file_options is None: + file_options = {} + cache_control = file_options.pop("cache-control", None) + _data = {} + + upsert = file_options.pop("upsert", None) + if upsert: + file_options.update({"x-upsert": upsert}) + + metadata = file_options.pop("metadata", None) + file_opts_headers = file_options.pop("headers", None) + + headers = { + **self._client.headers, + **DEFAULT_FILE_OPTIONS, + **file_options, + } + + if metadata: + metadata_str = json.dumps(metadata) + headers["x-metadata"] = base64.b64encode(metadata_str.encode()) + _data.update({"metadata": metadata_str}) + + if file_opts_headers: + headers.update({**file_opts_headers}) + + # Only include x-upsert on a POST method + if method != "POST": + del headers["x-upsert"] + + filename = path.rsplit("/", maxsplit=1)[-1] + + if cache_control: + headers["cache-control"] = f"max-age={cache_control}" + _data.update({"cacheControl": cache_control}) + + if ( + isinstance(file, BufferedReader) + or isinstance(file, bytes) + or isinstance(file, FileIO) + ): + # bytes or byte-stream-like object received + files = {"file": (filename, file, headers.pop("content-type"))} + else: + # str or pathlib.path received + files = { + "file": ( + filename, + open(file, "rb"), + headers.pop("content-type"), + ) + } + + _path = self._get_final_path(path) + + response = self._request( + method, f"/object/{_path}", files=files, headers=headers, data=_data + ) + + data: UploadData = response.json() + + return UploadResponse(path=path, Key=data.get("Key")) + + def upload( + self, + path: str, + file: Union[BufferedReader, bytes, FileIO, str, Path], + file_options: Optional[FileOptions] = None, + ) -> UploadResponse: + """ + Uploads a file to an existing bucket. + + Parameters + ---------- + path + The relative file path including the bucket ID. Should be of the format `bucket/folder/subfolder/filename.png`. + The bucket must already exist before attempting to upload. + file + The File object to be stored in the bucket. or a async generator of chunks + file_options + HTTP headers. + """ + return self._upload_or_update("POST", path, file, file_options) + + def update( + self, + path: str, + file: Union[BufferedReader, bytes, FileIO, str, Path], + file_options: Optional[FileOptions] = None, + ) -> UploadResponse: + return self._upload_or_update("PUT", path, file, file_options) + + def _get_final_path(self, path: str) -> str: + return f"{self.id}/{path}" + + +@dataclass(repr=False) +class SyncBucket(BaseBucket): + """Represents a storage bucket.""" + + +@dataclass +class SyncBucketProxy(SyncBucketActionsMixin): + """A bucket proxy, this contains the minimum required fields to query the File API.""" + + id: str + _client: SyncClient = field(repr=False) diff --git a/.venv/lib/python3.12/site-packages/storage3/constants.py b/.venv/lib/python3.12/site-packages/storage3/constants.py new file mode 100644 index 00000000..7bfc2f32 --- /dev/null +++ b/.venv/lib/python3.12/site-packages/storage3/constants.py @@ -0,0 +1,15 @@ +DEFAULT_SEARCH_OPTIONS = { + "limit": 100, + "offset": 0, + "sortBy": { + "column": "name", + "order": "asc", + }, +} +DEFAULT_FILE_OPTIONS = { + "cache-control": "3600", + "content-type": "text/plain;charset=UTF-8", + "x-upsert": "false", +} + +DEFAULT_TIMEOUT = 20 diff --git a/.venv/lib/python3.12/site-packages/storage3/exceptions.py b/.venv/lib/python3.12/site-packages/storage3/exceptions.py new file mode 100644 index 00000000..098b9d11 --- /dev/null +++ b/.venv/lib/python3.12/site-packages/storage3/exceptions.py @@ -0,0 +1,33 @@ +from typing import TypedDict + +from .utils import StorageException + + +class StorageApiErrorDict(TypedDict): + name: str + message: str + status: int + + +class StorageApiError(StorageException): + """Error raised when an operation on the storage API fails.""" + + def __init__(self, message: str, code: str, status: int) -> None: + error_message = "{{'statusCode': {}, 'error': {}, 'message': {}}}".format( + status, + code, + message, + ) + super().__init__(error_message) + self.name = "StorageApiError" + self.message = message + self.code = code + self.status = status + + def to_dict(self) -> StorageApiErrorDict: + return { + "name": self.name, + "code": self.code, + "message": self.message, + "status": self.status, + } diff --git a/.venv/lib/python3.12/site-packages/storage3/types.py b/.venv/lib/python3.12/site-packages/storage3/types.py new file mode 100644 index 00000000..56ec998c --- /dev/null +++ b/.venv/lib/python3.12/site-packages/storage3/types.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +from dataclasses import asdict, dataclass +from datetime import datetime +from typing import Any, Dict, Literal, Optional, TypedDict, Union + +import dateutil.parser + +RequestMethod = Literal["GET", "POST", "DELETE", "PUT", "HEAD"] + + +@dataclass +class BaseBucket: + """Represents a file storage bucket.""" + + id: str + name: str + owner: str + public: bool + created_at: datetime + updated_at: datetime + file_size_limit: Optional[int] + allowed_mime_types: Optional[list[str]] + + def __post_init__(self) -> None: + # created_at and updated_at are returned by the API as ISO timestamps + # so we convert them to datetime objects + self.created_at = dateutil.parser.isoparse(self.created_at) # type: ignore + self.updated_at = dateutil.parser.isoparse(self.updated_at) # type: ignore + + +# used in bucket.list method's option parameter +class _sortByType(TypedDict, total=False): + column: str + order: Literal["asc", "desc"] + + +class SignedUploadURL(TypedDict): + signed_url: str + signedUrl: str + token: str + path: str + + +class CreateOrUpdateBucketOptions(TypedDict, total=False): + public: bool + file_size_limit: int + allowed_mime_types: list[str] + + +class ListBucketFilesOptions(TypedDict, total=False): + limit: int + offset: int + sortBy: _sortByType + search: str + + +class TransformOptions(TypedDict, total=False): + height: int + width: int + resize: Literal["cover", "contain", "fill"] + format: Literal["origin", "avif"] + quality: int + + +class URLOptions(TypedDict, total=False): + download: Union[str, bool] + transform: TransformOptions + + +class CreateSignedURLsOptions(TypedDict, total=False): + download: Union[str, bool] + + +class DownloadOptions(TypedDict, total=False): + transform: TransformOptions + + +FileOptions = TypedDict( + "FileOptions", + { + "cache-control": str, + "content-type": str, + "x-upsert": str, + "upsert": str, + "metadata": Dict[str, Any], + "headers": Dict[str, str], + }, + total=False, +) + + +class UploadData(TypedDict, total=False): + Id: str + Key: str + + +@dataclass +class UploadResponse: + path: str + full_path: str + fullPath: str + + def __init__(self, path, Key): + self.path = path + self.full_path = Key + self.fullPath = Key + + dict = asdict + + +class SignedUrlResponse(TypedDict): + signedURL: str + signedUrl: str + + +class CreateSignedUrlResponse(TypedDict): + error: Optional[str] + path: str + signedURL: str + signedUrl: str diff --git a/.venv/lib/python3.12/site-packages/storage3/utils.py b/.venv/lib/python3.12/site-packages/storage3/utils.py new file mode 100644 index 00000000..9689fc8e --- /dev/null +++ b/.venv/lib/python3.12/site-packages/storage3/utils.py @@ -0,0 +1,11 @@ +from httpx import AsyncClient as AsyncClient # noqa: F401 +from httpx import Client as BaseClient + + +class SyncClient(BaseClient): + def aclose(self) -> None: + self.close() + + +class StorageException(Exception): + """Error raised when an operation on the storage API fails.""" diff --git a/.venv/lib/python3.12/site-packages/storage3/version.py b/.venv/lib/python3.12/site-packages/storage3/version.py new file mode 100644 index 00000000..4bb32721 --- /dev/null +++ b/.venv/lib/python3.12/site-packages/storage3/version.py @@ -0,0 +1 @@ +__version__ = "0.11.3" # {x-release-please-version} |