diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a8252ece..161e00a6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,7 +6,6 @@ jobs: test: name: Test / OS ${{ matrix.os }} / Python ${{ matrix.python-version }} strategy: - max-parallel: 1 matrix: os: [ubuntu-latest] python-version: [3.7, 3.8, 3.9, '3.10'] diff --git a/Makefile b/Makefile index 0fd32734..3b7385b4 100644 --- a/Makefile +++ b/Makefile @@ -5,14 +5,12 @@ install_poetry: curl -sSL https://install.python-poetry.org | python - poetry install -tests: install tests_only tests_pre_commit - tests_pre_commit: poetry run pre-commit run --all-files -run_tests: tests - tests_only: - export SUPABASE_TEST_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlhdCI6MTYzNTAwODQ4NywiZXhwIjoxOTUwNTg0NDg3fQ.l8IgkO7TQokGSc9OJoobXIVXsOXkilXl4Ak6SCX5qI8" &&\ - export SUPABASE_TEST_URL="https://ibrydvrsxoapzgtnhpso.supabase.co" &&\ poetry run pytest --cov=./ --cov-report=xml --cov-report=html -vv + +tests: install tests_only tests_pre_commit + +run_tests: tests diff --git a/poetry.lock b/poetry.lock index 021837e1..5c077157 100644 --- a/poetry.lock +++ b/poetry.lock @@ -715,6 +715,17 @@ python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" [package.dependencies] six = ">=1.5" +[[package]] +name = "python-dotenv" +version = "0.19.2" +description = "Read key-value pairs from a .env file and set them as environment variables" +category = "dev" +optional = false +python-versions = ">=3.5" + +[package.extras] +cli = ["click (>=5.0)"] + [[package]] name = "python-gitlab" version = "2.10.1" @@ -1072,7 +1083,7 @@ testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest- [metadata] lock-version = "1.1" python-versions = "^3.7" -content-hash = "92ed2cb0cdba5d5738b765d454d3516e3c0746283a19d5a84c54a950c660b11f" +content-hash = "83fac6208969427607985d042f3b5208e75fe7a42cf08843ce26768120cc7819" [metadata.files] anyio = [ @@ -1544,6 +1555,10 @@ python-dateutil = [ {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, ] +python-dotenv = [ + {file = "python-dotenv-0.19.2.tar.gz", hash = "sha256:a5de49a31e953b45ff2d2fd434bbc2670e8db5273606c1e737cc6b93eff3655f"}, + {file = "python_dotenv-0.19.2-py2.py3-none-any.whl", hash = "sha256:32b2bdc1873fd3a3c346da1c6db83d0053c3c62f28f1f38516070c4c8971b1d3"}, +] python-gitlab = [ {file = "python-gitlab-2.10.1.tar.gz", hash = "sha256:7afa7d7c062fa62c173190452265a30feefb844428efc58ea5244f3b9fc0d40f"}, {file = "python_gitlab-2.10.1-py3-none-any.whl", hash = "sha256:581a219759515513ea9399e936ed7137437cfb681f52d2641626685c492c999d"}, diff --git a/pyproject.toml b/pyproject.toml index 5ec31493..645aec53 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,7 @@ isort = "^5.9.3" pytest-cov = "^3.0.0" commitizen = "^2.20.3" python-semantic-release = "^7.24.0" +python-dotenv = "^0.19.2" [tool.semantic_release] version_variable = "supabase/__init__.py:__version__" diff --git a/supabase/__init__.py b/supabase/__init__.py index 74e0a0b4..1f7d945d 100644 --- a/supabase/__init__.py +++ b/supabase/__init__.py @@ -4,4 +4,4 @@ from supabase.client import Client, create_client from supabase.lib.auth_client import SupabaseAuthClient from supabase.lib.realtime_client import SupabaseRealtimeClient -from supabase.lib.storage_client import SupabaseStorageClient +from supabase.lib.storage_client import StorageFileAPI, SupabaseStorageClient diff --git a/supabase/lib/storage/storage_bucket_api.py b/supabase/lib/storage/storage_bucket_api.py index ae6382c4..c9e381b5 100644 --- a/supabase/lib/storage/storage_bucket_api.py +++ b/supabase/lib/storage/storage_bucket_api.py @@ -48,7 +48,7 @@ class StorageBucketAPI: """This class abstracts access to the endpoint to the Get, List, Empty, and Delete operations on a bucket""" def __init__( - self, url: str, headers: dict[str, str], is_async: bool = False + self, url: str, headers: Dict[str, str], is_async: bool = False ) -> None: self.url = url self.headers = headers @@ -64,7 +64,7 @@ def _request( self, method: _RequestMethod, url: str, - json: Optional[dict[Any, Any]] = None, + json: Optional[Dict[Any, Any]] = None, response_class: Optional[Type] = None, ) -> Any: if self._is_async: @@ -76,7 +76,7 @@ def _sync_request( self, method: _RequestMethod, url: str, - json: Optional[dict[Any, Any]] = None, + json: Optional[Dict[Any, Any]] = None, response_class: Optional[Type] = None, ) -> ResponseType: if isinstance(self._client, AsyncClient): # only to appease the type checker @@ -102,7 +102,7 @@ async def _async_request( self, method: _RequestMethod, url: str, - json: Optional[dict[Any, Any]] = None, + json: Optional[Dict[Any, Any]] = None, response_class: Optional[Type] = None, ) -> ResponseType: if isinstance(self._client, Client): # only to appease the type checker @@ -124,7 +124,7 @@ async def _async_request( else: return response_class(**response_data) - def list_buckets(self) -> Union[list[Bucket], Awaitable[list[Bucket]], None]: + def list_buckets(self) -> Union[List[Bucket], Awaitable[List[Bucket]], None]: """Retrieves the details of all storage buckets within an existing product.""" return self._request("GET", f"{self.url}/bucket", response_class=Bucket) @@ -140,7 +140,7 @@ def get_bucket(self, id: str) -> Union[Bucket, Awaitable[Bucket], None]: def create_bucket( self, id: str, name: str = None, public: bool = False - ) -> Union[dict[str, str], Awaitable[dict[str, str]]]: + ) -> Union[Dict[str, str], Awaitable[Dict[str, str]]]: """Creates a new storage bucket. Parameters @@ -158,7 +158,7 @@ def create_bucket( json={"id": id, "name": name or id, "public": public}, ) - def empty_bucket(self, id: str) -> Union[dict[str, str], Awaitable[dict[str, str]]]: + def empty_bucket(self, id: str) -> Union[Dict[str, str], Awaitable[Dict[str, str]]]: """Removes all objects inside a single bucket. Parameters @@ -170,7 +170,7 @@ def empty_bucket(self, id: str) -> Union[dict[str, str], Awaitable[dict[str, str def delete_bucket( self, id: str - ) -> Union[dict[str, str], Awaitable[dict[str, str]]]: + ) -> Union[Dict[str, str], Awaitable[Dict[str, str]]]: """Deletes an existing bucket. Note that you cannot delete buckets with existing objects inside. You must first `empty()` the bucket. diff --git a/tests/conftest.py b/tests/conftest.py index 568a211b..0f616ade 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,10 +3,15 @@ import os import pytest +from dotenv import load_dotenv from supabase import Client, create_client +def pytest_configure(config) -> None: + load_dotenv(dotenv_path="tests/tests.env") + + @pytest.fixture(scope="session") def supabase() -> Client: url = os.environ.get("SUPABASE_TEST_URL") diff --git a/tests/test_client.py b/tests/test_client.py index 9282715f..891a1832 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,27 +1,8 @@ from __future__ import annotations -import random -import string -from typing import TYPE_CHECKING, Any, Union +from typing import Any import pytest -from gotrue import Session, User - -if TYPE_CHECKING: - from supabase import Client - - -def _random_string(length: int = 10) -> str: - """Generate random string.""" - return "".join(random.choices(string.ascii_uppercase + string.digits, k=length)) - - -def _assert_authenticated_user(data: Union[Session, User, str, None]) -> None: - """Raise assertion error if user is not logged in correctly.""" - assert data is not None - assert isinstance(data, Session) - assert data.user is not None - assert data.user.aud == "authenticated" @pytest.mark.xfail( @@ -34,85 +15,3 @@ def test_incorrect_values_dont_instanciate_client(url: Any, key: Any) -> None: from supabase import Client, create_client _: Client = create_client(url, key) - - -@pytest.mark.skip(reason="TO FIX: Session does not terminate with test included.") -def test_client_auth(supabase: Client) -> None: - """Ensure we can create an auth user, and login with it.""" - # Create a random user login email and password. - random_email = f"{_random_string(10)}@supamail.com" - random_password = _random_string(20) - # Sign up (and sign in). - user = supabase.auth.sign_up( - email=random_email, - password=random_password, - phone=None, - ) - _assert_authenticated_user(user) - # Sign out. - supabase.auth.sign_out() - assert supabase.auth.user() is None - assert supabase.auth.session() is None - # Sign in (explicitly this time). - user = supabase.auth.sign_in(email=random_email, password=random_password) - _assert_authenticated_user(user) - - -def test_client_select(supabase: Client) -> None: - """Ensure we can select data from a table.""" - # TODO(fedden): Add this set back in (and expand on it) when postgrest and - # realtime libs are working. - data, _ = supabase.table("countries").select("*").execute() - # Assert we pulled real data. - assert data - - -def test_client_insert(supabase: Client) -> None: - """Ensure we can select data from a table.""" - data, _ = supabase.table("countries").select("*").execute() - # Assert we pulled real data. - previous_length = len(data) - new_row = { - "name": "test name", - "iso2": "test iso2", - "iso3": "test iso3", - "local_name": "test local name", - "continent": None, - } - result, _ = supabase.table("countries").insert(new_row).execute() - # Check returned result for insert was valid. - assert result - data, _ = supabase.table("countries").select("*").execute() - current_length = len(data) - # Ensure we've added a row remotely. - assert current_length == previous_length + 1 - - -@pytest.mark.skip(reason="missing permissions on test instance") -def test_client_upload_file(supabase: Client) -> None: - """Ensure we can upload files to a bucket""" - - TEST_BUCKET_NAME = "atestbucket" - - storage = supabase.storage() - storage_file = storage.StorageFileAPI(TEST_BUCKET_NAME) - - filename = "test.jpeg" - filepath = f"tests/{filename}" - mimetype = "image/jpeg" - options = {"contentType": mimetype} - - storage_file.upload(filename, filepath, options) - files = storage_file.list() - assert files - - image_info = None - for item in files: - if item.get("name") == filename: - image_info = item - break - - assert image_info is not None - assert image_info.get("metadata", {}).get("mimetype") == mimetype - - storage_file.remove([filename]) diff --git a/tests/test_storage.py b/tests/test_storage.py new file mode 100644 index 00000000..d91cd89c --- /dev/null +++ b/tests/test_storage.py @@ -0,0 +1,131 @@ +from __future__ import annotations + +from time import sleep +from typing import TYPE_CHECKING +from uuid import uuid4 + +import pytest + +if TYPE_CHECKING: + from pathlib import Path + from typing import Any, Callable, Dict, List + + from supabase import Client, StorageFileAPI, SupabaseStorageClient + + +UUID_PREFIX = "pytest-" + + +@pytest.fixture(scope="module") +def uuid_factory() -> Callable[[], str]: + def method() -> str: + """Generate a UUID""" + uuid = uuid4().hex[:8] # Get the first 8 digits part to make it shorter + return f"{UUID_PREFIX}{uuid}" + + return method + + +@pytest.fixture(scope="module") +def storage_client(supabase: Client) -> SupabaseStorageClient: + """Creates the storage client for the whole storage tests run""" + return supabase.storage() + + +@pytest.fixture(scope="module", autouse=True) +def delete_left_buckets( + request: pytest.FixtureRequest, storage_client: SupabaseStorageClient +): + """Ensures no test buckets are left""" + + def finalizer(): + # Sleep 15 seconds in order to let buckets be deleted before the double-check + sleep(15) + buckets_list = storage_client.list_buckets() + if not buckets_list: + return + + for bucket in buckets_list: + if bucket.id.startswith(UUID_PREFIX): + storage_client.empty_bucket(bucket.id) + storage_client.delete_bucket(bucket.id) + + request.addfinalizer(finalizer) + + +@pytest.fixture(scope="module") +def bucket( + storage_client: SupabaseStorageClient, uuid_factory: Callable[[], str] +) -> str: + """Creates a test bucket which will be used in the whole storage tests run and deleted at the end""" + bucket_id = uuid_factory() + storage_client.create_bucket(id=bucket_id) + + yield bucket_id + + storage_client.empty_bucket(bucket_id) + storage_client.delete_bucket(bucket_id) + + +@pytest.fixture(scope="module") +def storage_file_client( + storage_client: SupabaseStorageClient, bucket: str +) -> StorageFileAPI: + """Creates the storage file client for the whole storage tests run""" + yield storage_client.StorageFileAPI(bucket) + + +@pytest.fixture +def file(tmp_path: Path, uuid_factory: Callable[[], str]) -> Dict[str, str]: + """Creates a different test file (same content but different path) for each test""" + file_name = "test_image.svg" + file_content = ( + b' ' + b' ' + b' ' + b' ' + b' ' + ) + bucket_folder = uuid_factory() + bucket_path = f"{bucket_folder}/{file_name}" + file_path = tmp_path / file_name + with open(file_path, "wb") as f: + f.write(file_content) + + return { + "name": file_name, + "local_path": str(file_path), + "bucket_folder": bucket_folder, + "bucket_path": bucket_path, + "mime_type": "image/svg+xml", + } + + +# TODO: Test create_bucket, delete_bucket, empty_bucket, list_buckets, fileAPI.list before upload test + + +def test_client_upload_file( + storage_file_client: StorageFileAPI, file: Dict[str, str] +) -> None: + """Ensure we can upload files to a bucket""" + + file_name = file["name"] + file_path = file["local_path"] + mime_type = file["mime_type"] + bucket_file_path = file["bucket_path"] + bucket_folder = file["bucket_folder"] + options = {"content-type": mime_type} + + storage_file_client.upload(bucket_file_path, file_path, options) + files: List[Dict[str, Any]] = storage_file_client.list(bucket_folder) + image_info = next((f for f in files if f.get("name") == file_name), None) + + assert files + assert image_info is not None + assert image_info.get("metadata", {}).get("mimetype") == mime_type diff --git a/tests/tests.env b/tests/tests.env new file mode 100644 index 00000000..6a02ab7f --- /dev/null +++ b/tests/tests.env @@ -0,0 +1,2 @@ +SUPABASE_TEST_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlhdCI6MTYzNTAwODQ4NywiZXhwIjoxOTUwNTg0NDg3fQ.l8IgkO7TQokGSc9OJoobXIVXsOXkilXl4Ak6SCX5qI8" +SUPABASE_TEST_URL="https://ibrydvrsxoapzgtnhpso.supabase.co"