Skip to content

Commit ea44ab6

Browse files
authored
feat(storage): mypy storage (#1221)
1 parent d688a1f commit ea44ab6

File tree

11 files changed

+141
-71
lines changed

11 files changed

+141
-71
lines changed

src/storage/Makefile

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,47 @@
1+
help::
2+
@echo "Available commands"
3+
@echo " help -- (default) print this message"
4+
15
start-infra:
26
supabase --workdir infra start -x studio,gotrue,postgrest,mailpit,realtime,edge-runtime,logflare,vector,supavisor
7+
help::
8+
@echo " start-infra -- start containers for tests"
39

410
stop-infra:
511
supabase --workdir infra stop
12+
help::
13+
@echo " stop-infra -- stop containers for tests"
14+
15+
tests: mypy pytest
16+
help::
17+
@echo " tests -- run all tests for storage3"
618

7-
tests: pytest
19+
mypy:
20+
uv run --package storage3 mypy src/storage3 tests
21+
help::
22+
@echo " mypy -- run mypy on storage3"
823

924
pytest: start-infra
1025
uv run --package storage3 pytest --cov=./ --cov-report=xml --cov-report=html -vv
26+
help::
27+
@echo " pytest -- run pytest on storage3"
1128

1229
build-sync:
1330
uv run --package storage3 run-unasync.py
1431
sed -i '0,/SyncMock, /{s/SyncMock, //}' tests/_sync/test_bucket.py tests/_sync/test_client.py
1532
sed -i 's/SyncMock/Mock/g' tests/_sync/test_bucket.py tests/_sync/test_client.py
16-
sed -i 's/SyncClient/Client/g' storage3/_sync/client.py storage3/_sync/bucket.py storage3/_sync/file_api.py tests/_sync/test_bucket.py tests/_sync/test_client.py
17-
sed -i 's/self\.session\.aclose/self\.session\.close/g' storage3/_sync/client.py
33+
sed -i 's/SyncClient/Client/g' src/storage3/_sync/client.py src/storage3/_sync/bucket.py src/storage3/_sync/file_api.py tests/_sync/test_bucket.py tests/_sync/test_client.py
34+
sed -i 's/self\.session\.aclose/self\.session\.close/g' src/storage3/_sync/client.py
35+
help::
36+
@echo " build-sync -- generate _sync from _async implementation"
1837

1938
clean:
2039
rm -rf htmlcov .pytest_cache .mypy_cache .ruff_cache
2140
rm -f .coverage coverage.xml
41+
help::
42+
@echo " clean -- clean intermediary files"
2243

2344
build:
2445
uv build --package storage3
46+
help::
47+
@echo " build -- invoke uv build on storage3 package"

src/storage/pyproject.toml

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,10 @@ repository = "https://github.com/supabase/supabase-py"
3636
lints = [
3737
"pre-commit >=4.2.0",
3838
"ruff >=0.12.1",
39-
"unasync >= 0.6.0"
39+
"unasync >= 0.6.0",
40+
"python-lsp-server (>=1.12.2,<2.0.0)",
41+
"pylsp-mypy (>=0.7.0,<0.8.0)",
42+
"python-lsp-ruff (>=2.2.2,<3.0.0)",
4043
]
4144
docs = [
4245
"Sphinx >=7.1.2",
@@ -52,7 +55,6 @@ tests = [
5255
dev = [
5356
{ include-group = "lints" },
5457
{ include-group = "tests" },
55-
{ include-group = "lints" },
5658
{ include-group = "docs" },
5759
]
5860

@@ -85,6 +87,10 @@ filterwarnings = [
8587
"ignore::DeprecationWarning", # ignore deprecation warnings globally
8688
]
8789

90+
[tool.mypy]
91+
follow_untyped_imports = true # for deprecation module that does not have stubs
92+
allow_redefinition = true
93+
8894
[tool.uv]
8995
default-groups = [ "dev" ]
9096

src/storage/run-unasync.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import unasync
44

5-
paths = Path("src/functions").glob("**/*.py")
5+
paths = Path("src/storage3").glob("**/*.py")
66
tests = Path("tests").glob("**/*.py")
77

88
rules = (unasync._DEFAULT_RULE,)

src/storage/src/storage3/_async/file_api.py

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
RequestMethod,
2323
SignedUploadURL,
2424
SignedUrlResponse,
25+
TransformOptions,
2526
UploadData,
2627
UploadResponse,
2728
URLOptions,
@@ -165,7 +166,7 @@ async def create_signed_url(
165166
options
166167
options to be passed for downloading or transforming the file.
167168
"""
168-
json = {"expiresIn": str(expires_in)}
169+
json: dict[str, str | bool | TransformOptions] = {"expiresIn": str(expires_in)}
169170
download_query = ""
170171
if options.get("download"):
171172
json.update({"download": options["download"]})
@@ -209,7 +210,10 @@ async def create_signed_urls(
209210
options
210211
options to be passed for downloading the file.
211212
"""
212-
json = {"paths": paths, "expiresIn": str(expires_in)}
213+
json: dict[str, str | bool | None | list[str]] = {
214+
"paths": paths,
215+
"expiresIn": str(expires_in),
216+
}
213217
download_query = ""
214218
if options.get("download"):
215219
json.update({"download": options.get("download")})
@@ -265,9 +269,7 @@ async def get_public_url(self, path: str, options: URLOptions = {}) -> str:
265269

266270
render_path = "render/image" if options.get("transform") else "object"
267271
transformation_query = (
268-
urllib.parse.urlencode(options.get("transform"))
269-
if options.get("transform")
270-
else None
272+
urllib.parse.urlencode(t) if (t := options.get("transform")) else None
271273
)
272274

273275
if transformation_query:
@@ -322,7 +324,7 @@ async def copy(self, from_path: str, to_path: str) -> dict[str, str]:
322324
)
323325
return res.json()
324326

325-
async def remove(self, paths: list) -> list[dict[str, Any]]:
327+
async def remove(self, paths: list[str]) -> list[dict[str, Any]]:
326328
"""
327329
Deletes files within the same bucket
328330
@@ -341,7 +343,7 @@ async def remove(self, paths: list) -> list[dict[str, Any]]:
341343
async def info(
342344
self,
343345
path: str,
344-
) -> list[dict[str, str]]:
346+
) -> dict[str, Any]:
345347
"""
346348
Lists info for a particular file.
347349
@@ -381,7 +383,7 @@ async def list(
381383
self,
382384
path: Optional[str] = None,
383385
options: Optional[ListBucketFilesOptions] = None,
384-
) -> list[dict[str, str]]:
386+
) -> list[dict[str, Any]]:
385387
"""
386388
Lists all the files within a bucket.
387389

src/storage/src/storage3/_sync/file_api.py

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
RequestMethod,
2323
SignedUploadURL,
2424
SignedUrlResponse,
25+
TransformOptions,
2526
UploadData,
2627
UploadResponse,
2728
URLOptions,
@@ -165,7 +166,7 @@ def create_signed_url(
165166
options
166167
options to be passed for downloading or transforming the file.
167168
"""
168-
json = {"expiresIn": str(expires_in)}
169+
json: dict[str, str | bool | TransformOptions] = {"expiresIn": str(expires_in)}
169170
download_query = ""
170171
if options.get("download"):
171172
json.update({"download": options["download"]})
@@ -209,7 +210,10 @@ def create_signed_urls(
209210
options
210211
options to be passed for downloading the file.
211212
"""
212-
json = {"paths": paths, "expiresIn": str(expires_in)}
213+
json: dict[str, str | bool | None | list[str]] = {
214+
"paths": paths,
215+
"expiresIn": str(expires_in),
216+
}
213217
download_query = ""
214218
if options.get("download"):
215219
json.update({"download": options.get("download")})
@@ -265,9 +269,7 @@ def get_public_url(self, path: str, options: URLOptions = {}) -> str:
265269

266270
render_path = "render/image" if options.get("transform") else "object"
267271
transformation_query = (
268-
urllib.parse.urlencode(options.get("transform"))
269-
if options.get("transform")
270-
else None
272+
urllib.parse.urlencode(t) if (t := options.get("transform")) else None
271273
)
272274

273275
if transformation_query:
@@ -322,7 +324,7 @@ def copy(self, from_path: str, to_path: str) -> dict[str, str]:
322324
)
323325
return res.json()
324326

325-
def remove(self, paths: list) -> list[dict[str, Any]]:
327+
def remove(self, paths: list[str]) -> list[dict[str, Any]]:
326328
"""
327329
Deletes files within the same bucket
328330
@@ -341,7 +343,7 @@ def remove(self, paths: list) -> list[dict[str, Any]]:
341343
def info(
342344
self,
343345
path: str,
344-
) -> list[dict[str, str]]:
346+
) -> dict[str, Any]:
345347
"""
346348
Lists info for a particular file.
347349
@@ -381,7 +383,7 @@ def list(
381383
self,
382384
path: Optional[str] = None,
383385
options: Optional[ListBucketFilesOptions] = None,
384-
) -> list[dict[str, str]]:
386+
) -> list[dict[str, Any]]:
385387
"""
386388
Lists all the files within a bucket.
387389

src/storage/src/storage3/exceptions.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
class StorageApiErrorDict(TypedDict):
77
name: str
88
message: str
9+
code: str
910
status: int
1011

1112

src/storage/src/storage3/py.typed

Whitespace-only changes.

src/storage/tests/_async/test_client.py

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
from collections.abc import AsyncGenerator, Generator
34
from dataclasses import dataclass
45
from typing import TYPE_CHECKING
56
from unittest.mock import AsyncMock, Mock, patch
@@ -22,7 +23,7 @@
2223

2324

2425
# Global variable to track the ids from the buckets created in the tests run
25-
temp_test_buckets_ids = []
26+
temp_test_buckets_ids: list[str] = []
2627

2728

2829
@pytest.fixture
@@ -56,14 +57,10 @@ async def afinalizer():
5657
request.addfinalizer(AsyncFinalizerFactory(afinalizer).finalizer)
5758

5859

59-
async def bucket_factory(
60-
storage: AsyncStorageClient, uuid_factory: Callable[[], str], public: bool
61-
) -> str:
62-
"""Creates a test bucket which will be used in the whole storage tests run and deleted at the end"""
63-
64-
6560
@pytest.fixture
66-
async def bucket(storage: AsyncStorageClient, uuid_factory: Callable[[], str]) -> str:
61+
async def bucket(
62+
storage: AsyncStorageClient, uuid_factory: Callable[[], str]
63+
) -> AsyncGenerator[str]:
6764
"""Creates a test bucket which will be used in the whole storage tests run and deleted at the end"""
6865
bucket_id = uuid_factory()
6966

@@ -84,7 +81,7 @@ async def bucket(storage: AsyncStorageClient, uuid_factory: Callable[[], str]) -
8481
@pytest.fixture
8582
async def public_bucket(
8683
storage: AsyncStorageClient, uuid_factory: Callable[[], str]
87-
) -> str:
84+
) -> AsyncGenerator[str]:
8885
"""Creates a test public bucket which will be used in the whole storage tests run and deleted at the end"""
8986
bucket_id = uuid_factory()
9087

@@ -103,15 +100,17 @@ async def public_bucket(
103100

104101

105102
@pytest.fixture
106-
def storage_file_client(storage: AsyncStorageClient, bucket: str) -> AsyncBucketProxy:
103+
def storage_file_client(
104+
storage: AsyncStorageClient, bucket: str
105+
) -> Generator[AsyncBucketProxy]:
107106
"""Creates the storage file client for the whole storage tests run"""
108107
yield storage.from_(bucket)
109108

110109

111110
@pytest.fixture
112111
def storage_file_client_public(
113112
storage: AsyncStorageClient, public_bucket: str
114-
) -> AsyncBucketProxy:
113+
) -> Generator[AsyncBucketProxy]:
115114
"""Creates the storage file client for the whole storage tests run"""
116115
yield storage.from_(public_bucket)
117116

@@ -281,6 +280,7 @@ async def test_client_upload(
281280
image_info = next((f for f in files if f.get("name") == file.name), None)
282281

283282
assert image == file.file_content
283+
assert image_info is not None
284284
assert image_info.get("metadata", {}).get("mimetype") == file.mime_type
285285

286286

@@ -308,6 +308,7 @@ async def test_client_update(
308308
)
309309

310310
assert image == two_files[1].file_content
311+
assert image_info is not None
311312
assert image_info.get("metadata", {}).get("mimetype") == two_files[1].mime_type
312313

313314

@@ -339,6 +340,7 @@ async def test_client_upload_to_signed_url(
339340
image_info = next((f for f in files if f.get("name") == file.name), None)
340341

341342
assert image == file.file_content
343+
assert image_info is not None
342344
assert image_info.get("metadata", {}).get("mimetype") == file.mime_type
343345

344346
# Test with file_options=None
@@ -586,6 +588,7 @@ async def test_client_copy(
586588
copied_info = next(
587589
(f for f in files if f.get("name") == f"copied_{file.name}"), None
588590
)
591+
assert copied_info is not None
589592
assert copied_info.get("metadata", {}).get("mimetype") == file.mime_type
590593

591594

@@ -612,6 +615,7 @@ async def test_client_move(
612615
# Verify metadata was preserved
613616
files = await storage_file_client.list(file.bucket_folder)
614617
moved_info = next((f for f in files if f.get("name") == f"moved_{file.name}"), None)
618+
assert moved_info is not None
615619
assert moved_info.get("metadata", {}).get("mimetype") == file.mime_type
616620

617621

@@ -628,7 +632,7 @@ async def test_client_remove(
628632
assert await storage_file_client.exists(file.bucket_path)
629633

630634
# Remove file
631-
await storage_file_client.remove(file.bucket_path)
635+
await storage_file_client.remove([file.bucket_path])
632636

633637
# Verify file no longer exists
634638
assert not await storage_file_client.exists(file.bucket_path)

src/storage/tests/_sync/conftest.py

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,18 +14,7 @@ def pytest_configure(config) -> None:
1414
load_dotenv(dotenv_path="tests/tests.env")
1515

1616

17-
@pytest.fixture(scope="package")
18-
def event_loop() -> Generator[asyncio.AbstractEventLoop]:
19-
"""Returns an event loop for the current thread"""
20-
try:
21-
loop = asyncio.get_running_loop()
22-
except RuntimeError:
23-
loop = asyncio.new_event_loop()
24-
yield loop
25-
loop.close()
26-
27-
28-
@pytest.fixture(scope="package")
17+
@pytest.fixture
2918
def storage() -> Generator[SyncStorageClient]:
3019
url = os.environ.get("SUPABASE_TEST_URL")
3120
assert url is not None, "Must provide SUPABASE_TEST_URL environment variable"

0 commit comments

Comments
 (0)