Skip to content
This repository was archived by the owner on Sep 8, 2025. It is now read-only.

Commit da4d785

Browse files
committed
feat: add update existing file function
1 parent fc8cb5d commit da4d785

File tree

2 files changed

+125
-6
lines changed

2 files changed

+125
-6
lines changed

storage3/_async/file_api.py

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from dataclasses import dataclass, field
55
from io import BufferedReader, FileIO
66
from pathlib import Path
7-
from typing import Any, Optional, Union, cast
7+
from typing import Any, Literal, Optional, Union, cast
88

99
from httpx import HTTPError, Response
1010

@@ -344,8 +344,9 @@ async def download(self, path: str, options: DownloadOptions = {}) -> bytes:
344344
)
345345
return response.content
346346

347-
async def upload(
347+
async def _upload_or_update(
348348
self,
349+
method: Literal["POST", "PUT"],
349350
path: str,
350351
file: Union[BufferedReader, bytes, FileIO, str, Path],
351352
file_options: Optional[FileOptions] = None,
@@ -367,9 +368,6 @@ async def upload(
367368
file_options = {}
368369
cache_control = file_options.get("cache-control")
369370
_data = {}
370-
if cache_control:
371-
file_options["cache-control"] = f"max-age={cache_control}"
372-
_data = {"cacheControl": cache_control}
373371

374372
headers = {
375373
**self._client.headers,
@@ -378,6 +376,10 @@ async def upload(
378376
}
379377
filename = path.rsplit("/", maxsplit=1)[-1]
380378

379+
if cache_control:
380+
headers["cache-control"] = f"max-age={cache_control}"
381+
_data = {"cacheControl": cache_control}
382+
381383
if (
382384
isinstance(file, BufferedReader)
383385
or isinstance(file, bytes)
@@ -398,9 +400,38 @@ async def upload(
398400
_path = self._get_final_path(path)
399401

400402
return await self._request(
401-
"POST", f"/object/{_path}", files=files, headers=headers, data=_data
403+
method, f"/object/{_path}", files=files, headers=headers, data=_data
402404
)
403405

406+
async def upload(
407+
self,
408+
path: str,
409+
file: Union[BufferedReader, bytes, FileIO, str, Path],
410+
file_options: Optional[FileOptions] = None,
411+
) -> Response:
412+
"""
413+
Uploads a file to an existing bucket.
414+
415+
Parameters
416+
----------
417+
path
418+
The relative file path including the bucket ID. Should be of the format `bucket/folder/subfolder/filename.png`.
419+
The bucket must already exist before attempting to upload.
420+
file
421+
The File object to be stored in the bucket. or a async generator of chunks
422+
file_options
423+
HTTP headers.
424+
"""
425+
return await self._upload_or_update("POST", path, file, file_options)
426+
427+
async def update(
428+
self,
429+
path: str,
430+
file: Union[BufferedReader, bytes, FileIO, str, Path],
431+
file_options: Optional[FileOptions] = None,
432+
) -> Response:
433+
return await self._upload_or_update("PUT", path, file, file_options)
434+
404435
def _get_final_path(self, path: str) -> str:
405436
return f"{self.id}/{path}"
406437

tests/_async/test_client.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,67 @@ def file(tmp_path: Path, uuid_factory: Callable[[], str]) -> FileForTesting:
156156
)
157157

158158

159+
@pytest.fixture
160+
def two_files(tmp_path: Path, uuid_factory: Callable[[], str]) -> list[FileForTesting]:
161+
"""Creates multiple test files (different content, same bucket/folder path, different file names)"""
162+
file_name_1 = "test_image_1.svg"
163+
file_name_2 = "test_image_2.svg"
164+
file_content = (
165+
b'<svg width="109" height="113" viewBox="0 0 109 113" fill="none" xmlns="http://www.w3.org/2000/svg"> '
166+
b'<path d="M63.7076 110.284C60.8481 113.885 55.0502 111.912 54.9813 107.314L53.9738 40.0627L99.1935 '
167+
b'40.0627C107.384 40.0627 111.952 49.5228 106.859 55.9374L63.7076 110.284Z" fill="url(#paint0_linear)"/> '
168+
b'<path d="M63.7076 110.284C60.8481 113.885 55.0502 111.912 54.9813 107.314L53.9738 40.0627L99.1935 '
169+
b'40.0627C107.384 40.0627 111.952 49.5228 106.859 55.9374L63.7076 110.284Z" fill="url(#paint1_linear)" '
170+
b'fill-opacity="0.2"/> <path d="M45.317 2.07103C48.1765 -1.53037 53.9745 0.442937 54.0434 5.041L54.4849 '
171+
b'72.2922H9.83113C1.64038 72.2922 -2.92775 62.8321 2.1655 56.4175L45.317 2.07103Z" fill="#3ECF8E"/> <defs>'
172+
b'<linearGradient id="paint0_linear" x1="53.9738" y1="54.974" x2="94.1635" y2="71.8295"'
173+
b'gradientUnits="userSpaceOnUse"> <stop stop-color="#249361"/> <stop offset="1" stop-color="#3ECF8E"/> '
174+
b'</linearGradient> <linearGradient id="paint1_linear" x1="36.1558" y1="30.578" x2="54.4844" y2="65.0806" '
175+
b'gradientUnits="userSpaceOnUse"> <stop/> <stop offset="1" stop-opacity="0"/> </linearGradient> </defs> </svg>'
176+
)
177+
file_content_2 = (
178+
b'<svg width="119" height="123" viewBox="0 0 119 123" fill="none" xmlns="http://www.w3.org/2000/svg"> '
179+
b'<path d="M63.7076 110.284C60.8481 113.885 55.0502 111.912 54.9813 107.314L53.9738 40.0627L99.1935 '
180+
b'40.0627C107.384 40.0627 111.952 49.5228 106.859 55.9374L63.7076 110.284Z" fill="url(#paint0_linear)"/> '
181+
b'<path d="M63.7076 110.284C60.8481 113.885 55.0502 111.912 54.9813 107.314L53.9738 40.0627L99.1935 '
182+
b'40.0627C107.384 40.0627 111.952 49.5228 106.859 55.9374L63.7076 110.284Z" fill="url(#paint1_linear)" '
183+
b'fill-opacity="0.2"/> <path d="M45.317 2.07103C48.1765 -1.53037 53.9745 0.442937 54.0434 5.041L54.4849 '
184+
b'72.2922H9.83113C1.64038 72.2922 -2.92775 62.8321 2.1655 56.4175L45.317 2.07103Z" fill="#3FDF8E"/> <defs>'
185+
b'<linearGradient id="paint0_linear" x1="53.9738" y1="54.974" x2="94.1635" y2="71.8295"'
186+
b'gradientUnits="userSpaceOnUse"> <stop stop-color="#249361"/> <stop offset="1" stop-color="#3FDF8E"/> '
187+
b'</linearGradient> <linearGradient id="paint1_linear" x1="36.1558" y1="30.578" x2="54.4844" y2="65.0806" '
188+
b'gradientUnits="userSpaceOnUse"> <stop/> <stop offset="1" stop-opacity="0"/> </linearGradient> </defs> </svg>'
189+
)
190+
bucket_folder = uuid_factory()
191+
bucket_path_1 = f"{bucket_folder}/{file_name_1}"
192+
bucket_path_2 = f"{bucket_folder}/{file_name_2}"
193+
file_path_1 = tmp_path / file_name_1
194+
file_path_2 = tmp_path / file_name_2
195+
with open(file_path_1, "wb") as f:
196+
f.write(file_content)
197+
with open(file_path_2, "wb") as f:
198+
f.write(file_content_2)
199+
200+
return [
201+
FileForTesting(
202+
name=file_name_1,
203+
local_path=str(file_path_1),
204+
bucket_folder=bucket_folder,
205+
bucket_path=bucket_path_1,
206+
mime_type="image/svg+xml",
207+
file_content=file_content,
208+
),
209+
FileForTesting(
210+
name=file_name_2,
211+
local_path=str(file_path_2),
212+
bucket_folder=bucket_folder,
213+
bucket_path=bucket_path_2,
214+
mime_type="image/svg+xml",
215+
file_content=file_content_2,
216+
),
217+
]
218+
219+
159220
@pytest.fixture
160221
def multi_file(tmp_path: Path, uuid_factory: Callable[[], str]) -> list[FileForTesting]:
161222
"""Creates multiple test files (same content, same bucket/folder path, different file names)"""
@@ -223,6 +284,33 @@ async def test_client_upload(
223284
assert image_info.get("metadata", {}).get("mimetype") == file.mime_type
224285

225286

287+
async def test_client_update(
288+
storage_file_client: AsyncBucketProxy,
289+
two_files: list[FileForTesting],
290+
) -> None:
291+
"""Ensure we can upload files to a bucket"""
292+
await storage_file_client.upload(
293+
two_files[0].bucket_path,
294+
two_files[0].local_path,
295+
{"content-type": two_files[0].mime_type},
296+
)
297+
298+
await storage_file_client.update(
299+
two_files[0].bucket_path,
300+
two_files[1].local_path,
301+
{"content-type": two_files[1].mime_type},
302+
)
303+
304+
image = await storage_file_client.download(two_files[0].bucket_path)
305+
file_list = await storage_file_client.list(two_files[0].bucket_folder)
306+
image_info = next(
307+
(f for f in file_list if f.get("name") == two_files[0].name), None
308+
)
309+
310+
assert image == two_files[1].file_content
311+
assert image_info.get("metadata", {}).get("mimetype") == two_files[1].mime_type
312+
313+
226314
@pytest.mark.parametrize(
227315
"path", ["foobar.txt", "example/nested.jpg", "/leading/slash.png"]
228316
)

0 commit comments

Comments
 (0)