Skip to content

Commit d4f9794

Browse files
authored
feat: add custom-metadata to upload (#328)
1 parent eb8034f commit d4f9794

File tree

7 files changed

+113
-20
lines changed

7 files changed

+113
-20
lines changed

storage3/_async/file_api.py

+19-5
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
from __future__ import annotations
22

3+
import base64
4+
import json
35
import urllib.parse
46
from dataclasses import dataclass, field
57
from io import BufferedReader, FileIO
@@ -435,18 +437,30 @@ async def _upload_or_update(
435437
"""
436438
if file_options is None:
437439
file_options = {}
438-
cache_control = file_options.get("cache-control")
440+
cache_control = file_options.pop("cache-control", None)
439441
_data = {}
440-
if file_options.get("upsert"):
441-
file_options.update({"x-upsert": file_options.get("upsert")})
442-
del file_options["upsert"]
442+
443+
upsert = file_options.pop("upsert", None)
444+
if upsert:
445+
file_options.update({"x-upsert": upsert})
446+
447+
metadata = file_options.pop("metadata", None)
448+
file_opts_headers = file_options.pop("headers", None)
443449

444450
headers = {
445451
**self._client.headers,
446452
**DEFAULT_FILE_OPTIONS,
447453
**file_options,
448454
}
449455

456+
if metadata:
457+
metadata_str = json.dumps(metadata)
458+
headers["x-metadata"] = base64.b64encode(metadata_str.encode())
459+
_data.update({"metadata": metadata_str})
460+
461+
if file_opts_headers:
462+
headers.update({**file_opts_headers})
463+
450464
# Only include x-upsert on a POST method
451465
if method != "POST":
452466
del headers["x-upsert"]
@@ -455,7 +469,7 @@ async def _upload_or_update(
455469

456470
if cache_control:
457471
headers["cache-control"] = f"max-age={cache_control}"
458-
_data = {"cacheControl": cache_control}
472+
_data.update({"cacheControl": cache_control})
459473

460474
if (
461475
isinstance(file, BufferedReader)

storage3/_sync/file_api.py

+19-5
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
from __future__ import annotations
22

3+
import base64
4+
import json
35
import urllib.parse
46
from dataclasses import dataclass, field
57
from io import BufferedReader, FileIO
@@ -435,18 +437,30 @@ def _upload_or_update(
435437
"""
436438
if file_options is None:
437439
file_options = {}
438-
cache_control = file_options.get("cache-control")
440+
cache_control = file_options.pop("cache-control", None)
439441
_data = {}
440-
if file_options.get("upsert"):
441-
file_options.update({"x-upsert": file_options.get("upsert")})
442-
del file_options["upsert"]
442+
443+
upsert = file_options.pop("upsert", None)
444+
if upsert:
445+
file_options.update({"x-upsert": upsert})
446+
447+
metadata = file_options.pop("metadata", None)
448+
file_opts_headers = file_options.pop("headers", None)
443449

444450
headers = {
445451
**self._client.headers,
446452
**DEFAULT_FILE_OPTIONS,
447453
**file_options,
448454
}
449455

456+
if metadata:
457+
metadata_str = json.dumps(metadata)
458+
headers["x-metadata"] = base64.b64encode(metadata_str.encode())
459+
_data.update({"metadata": metadata_str})
460+
461+
if file_opts_headers:
462+
headers.update({**file_opts_headers})
463+
450464
# Only include x-upsert on a POST method
451465
if method != "POST":
452466
del headers["x-upsert"]
@@ -455,7 +469,7 @@ def _upload_or_update(
455469

456470
if cache_control:
457471
headers["cache-control"] = f"max-age={cache_control}"
458-
_data = {"cacheControl": cache_control}
472+
_data.update({"cacheControl": cache_control})
459473

460474
if (
461475
isinstance(file, BufferedReader)

storage3/types.py

+9-2
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from dataclasses import asdict, dataclass
44
from datetime import datetime
5-
from typing import Literal, Optional, TypedDict, Union
5+
from typing import Any, Dict, Literal, Optional, TypedDict, Union
66

77
import dateutil.parser
88

@@ -77,7 +77,14 @@ class DownloadOptions(TypedDict, total=False):
7777

7878
FileOptions = TypedDict(
7979
"FileOptions",
80-
{"cache-control": str, "content-type": str, "x-upsert": str, "upsert": str},
80+
{
81+
"cache-control": str,
82+
"content-type": str,
83+
"x-upsert": str,
84+
"upsert": str,
85+
"metadata": Dict[str, Any],
86+
"headers": Dict[str, str],
87+
},
8188
total=False,
8289
)
8390

tests/_async/conftest.py

+9-3
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import asyncio
44
import os
5+
from collections.abc import AsyncGenerator, Generator
56

67
import pytest
78
from dotenv import load_dotenv
@@ -14,13 +15,18 @@ def pytest_configure(config) -> None:
1415

1516

1617
@pytest.fixture(scope="package")
17-
def event_loop() -> asyncio.AbstractEventLoop:
18+
def event_loop() -> Generator[asyncio.AbstractEventLoop]:
1819
"""Returns an event loop for the current thread"""
19-
return asyncio.get_event_loop_policy().get_event_loop()
20+
try:
21+
loop = asyncio.get_running_loop()
22+
except RuntimeError:
23+
loop = asyncio.new_event_loop()
24+
yield loop
25+
loop.close()
2026

2127

2228
@pytest.fixture(scope="package")
23-
async def storage() -> AsyncStorageClient:
29+
async def storage() -> AsyncGenerator[AsyncStorageClient]:
2430
url = os.environ.get("SUPABASE_TEST_URL")
2531
assert url is not None, "Must provide SUPABASE_TEST_URL environment variable"
2632
key = os.environ.get("SUPABASE_TEST_KEY")

tests/_async/test_client.py

+25-2
Original file line numberDiff line numberDiff line change
@@ -398,8 +398,31 @@ async def test_client_get_public_url(
398398
assert response.content == file.file_content
399399

400400

401+
async def test_client_upload_with_custom_metadata(
402+
storage_file_client_public: AsyncBucketProxy, file: FileForTesting
403+
) -> None:
404+
"""Ensure we can get the public url of a file in a bucket"""
405+
await storage_file_client_public.upload(
406+
file.bucket_path,
407+
file.local_path,
408+
{
409+
"content-type": file.mime_type,
410+
"metadata": {"custom": "metadata", "second": "second", "third": "third"},
411+
},
412+
)
413+
414+
info = await storage_file_client_public.info(file.bucket_path)
415+
assert "metadata" in info.keys()
416+
assert info["name"] == file.bucket_path
417+
assert info["metadata"] == {
418+
"custom": "metadata",
419+
"second": "second",
420+
"third": "third",
421+
}
422+
423+
401424
async def test_client_info(
402-
storage_file_client_public: SyncBucketProxy, file: FileForTesting
425+
storage_file_client_public: AsyncBucketProxy, file: FileForTesting
403426
) -> None:
404427
"""Ensure we can get the public url of a file in a bucket"""
405428
await storage_file_client_public.upload(
@@ -413,7 +436,7 @@ async def test_client_info(
413436

414437

415438
async def test_client_exists(
416-
storage_file_client_public: SyncBucketProxy, file: FileForTesting
439+
storage_file_client_public: AsyncBucketProxy, file: FileForTesting
417440
) -> None:
418441
"""Ensure we can get the public url of a file in a bucket"""
419442
await storage_file_client_public.upload(

tests/_sync/conftest.py

+9-3
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import asyncio
44
import os
5+
from collections.abc import Generator
56

67
import pytest
78
from dotenv import load_dotenv
@@ -14,13 +15,18 @@ def pytest_configure(config) -> None:
1415

1516

1617
@pytest.fixture(scope="package")
17-
def event_loop() -> asyncio.AbstractEventLoop:
18+
def event_loop() -> Generator[asyncio.AbstractEventLoop]:
1819
"""Returns an event loop for the current thread"""
19-
return asyncio.get_event_loop_policy().get_event_loop()
20+
try:
21+
loop = asyncio.get_running_loop()
22+
except RuntimeError:
23+
loop = asyncio.new_event_loop()
24+
yield loop
25+
loop.close()
2026

2127

2228
@pytest.fixture(scope="package")
23-
def storage() -> SyncStorageClient:
29+
def storage() -> Generator[SyncStorageClient]:
2430
url = os.environ.get("SUPABASE_TEST_URL")
2531
assert url is not None, "Must provide SUPABASE_TEST_URL environment variable"
2632
key = os.environ.get("SUPABASE_TEST_KEY")

tests/_sync/test_client.py

+23
Original file line numberDiff line numberDiff line change
@@ -396,6 +396,29 @@ def test_client_get_public_url(
396396
assert response.content == file.file_content
397397

398398

399+
def test_client_upload_with_custom_metadata(
400+
storage_file_client_public: SyncBucketProxy, file: FileForTesting
401+
) -> None:
402+
"""Ensure we can get the public url of a file in a bucket"""
403+
storage_file_client_public.upload(
404+
file.bucket_path,
405+
file.local_path,
406+
{
407+
"content-type": file.mime_type,
408+
"metadata": {"custom": "metadata", "second": "second", "third": "third"},
409+
},
410+
)
411+
412+
info = storage_file_client_public.info(file.bucket_path)
413+
assert "metadata" in info.keys()
414+
assert info["name"] == file.bucket_path
415+
assert info["metadata"] == {
416+
"custom": "metadata",
417+
"second": "second",
418+
"third": "third",
419+
}
420+
421+
399422
def test_client_info(
400423
storage_file_client_public: SyncBucketProxy, file: FileForTesting
401424
) -> None:

0 commit comments

Comments
 (0)