From b228d2b4e460a79c622ec38ebde5a8352bcc110e Mon Sep 17 00:00:00 2001 From: Joseph Riddle Date: Tue, 14 Dec 2021 16:56:05 -0800 Subject: [PATCH] Add typed client options --- supabase/client.py | 52 ++++++++++++------------------ supabase/lib/client_options.py | 58 ++++++++++++++++++++++++++++++++++ supabase/lib/constants.py | 5 +-- supabase/lib/storage_client.py | 6 ++-- test.ps1 | 5 +++ tests/conftest.py | 6 ++-- tests/test_client_options.py | 39 +++++++++++++++++++++++ 7 files changed, 133 insertions(+), 38 deletions(-) create mode 100644 supabase/lib/client_options.py create mode 100644 test.ps1 create mode 100644 tests/test_client_options.py diff --git a/supabase/client.py b/supabase/client.py index a3fe494b..002c8175 100644 --- a/supabase/client.py +++ b/supabase/client.py @@ -1,22 +1,15 @@ -from typing import Any, Dict +from typing import Any, Coroutine, Dict +from httpx import Response from postgrest_py import PostgrestClient +from postgrest_py.request_builder import RequestBuilder from supabase.lib.auth_client import SupabaseAuthClient -from supabase.lib.constants import DEFAULT_HEADERS +from supabase.lib.client_options import ClientOptions +from supabase.lib.constants import DEFAULT_OPTIONS from supabase.lib.query_builder import SupabaseQueryBuilder -from supabase.lib.realtime_client import SupabaseRealtimeClient from supabase.lib.storage_client import SupabaseStorageClient -DEFAULT_OPTIONS = { - "schema": "public", - "auto_refresh_token": True, - "persist_session": True, - "detect_session_in_url": True, - "local_storage": {}, - "headers": DEFAULT_HEADERS, -} - class Client: """Supabase client class.""" @@ -47,19 +40,19 @@ def __init__( self.supabase_url = supabase_url self.supabase_key = supabase_key - settings = {**DEFAULT_OPTIONS, **options} - settings["headers"].update(self._get_auth_headers()) + settings = DEFAULT_OPTIONS.replace(**options) + settings.headers.update(self._get_auth_headers()) self.rest_url: str = f"{supabase_url}/rest/v1" self.realtime_url: str = f"{supabase_url}/realtime/v1".replace("http", "ws") self.auth_url: str = f"{supabase_url}/auth/v1" self.storage_url = f"{supabase_url}/storage/v1" - self.schema: str = settings.pop("schema") + self.schema: str = settings.schema # Instantiate clients. self.auth: SupabaseAuthClient = self._init_supabase_auth_client( auth_url=self.auth_url, supabase_key=self.supabase_key, - **settings, + client_options=settings, ) # TODO(fedden): Bring up to parity with JS client. # self.realtime: SupabaseRealtimeClient = self._init_realtime_client( @@ -70,14 +63,14 @@ def __init__( self.postgrest: PostgrestClient = self._init_postgrest_client( rest_url=self.rest_url, supabase_key=self.supabase_key, - **settings, + headers=settings.headers, ) - def storage(self): + def storage(self) -> SupabaseStorageClient: """Create instance of the storage client""" return SupabaseStorageClient(self.storage_url, self._get_auth_headers()) - def table(self, table_name: str): + def table(self, table_name: str) -> RequestBuilder: """Perform a table operation. Note that the supabase client uses the `from` method, but in Python, @@ -86,7 +79,7 @@ def table(self, table_name: str): """ return self.from_(table_name) - def from_(self, table_name: str): + def from_(self, table_name: str) -> RequestBuilder: """Perform a table operation. See the `table` method. @@ -100,7 +93,7 @@ def from_(self, table_name: str): ) return query_builder.from_(table_name) - def rpc(self, fn, params): + def rpc(self, fn, params) -> Coroutine[Any, Any, Response]: """Performs a stored procedure call. Parameters @@ -158,20 +151,16 @@ def _init_realtime_client( def _init_supabase_auth_client( auth_url: str, supabase_key: str, - detect_session_in_url: bool, - auto_refresh_token: bool, - persist_session: bool, - local_storage: Dict[str, Any], - headers: Dict[str, str], + client_options: ClientOptions, ) -> SupabaseAuthClient: """Creates a wrapped instance of the GoTrue Client.""" return SupabaseAuthClient( url=auth_url, - auto_refresh_token=auto_refresh_token, - detect_session_in_url=detect_session_in_url, - persist_session=persist_session, - local_storage=local_storage, - headers=headers, + auto_refresh_token=client_options.auto_refresh_token, + detect_session_in_url=client_options.detect_session_in_url, + persist_session=client_options.persist_session, + local_storage=client_options.local_storage, + headers=client_options.headers, ) @staticmethod @@ -179,7 +168,6 @@ def _init_postgrest_client( rest_url: str, supabase_key: str, headers: Dict[str, str], - **kwargs, # other unused settings ) -> PostgrestClient: """Private helper for creating an instance of the Postgrest client.""" client = PostgrestClient(rest_url, headers=headers) diff --git a/supabase/lib/client_options.py b/supabase/lib/client_options.py new file mode 100644 index 00000000..aecbd90f --- /dev/null +++ b/supabase/lib/client_options.py @@ -0,0 +1,58 @@ +import copy +import dataclasses +from typing import Any, Callable, Dict, Optional + +from supabase import __version__ + + +DEFAULT_HEADERS = {"X-Client-Info": f"supabase-py/{__version__}"} + + +@dataclasses.dataclass +class ClientOptions: + + """The Postgres schema which your tables belong to. Must be on the list of exposed schemas in Supabase. Defaults to 'public'.""" + + schema: str = "public" + + """Optional headers for initializing the client.""" + headers: Dict[str, str] = dataclasses.field(default_factory=DEFAULT_HEADERS.copy) + + """Automatically refreshes the token for logged in users.""" + auto_refresh_token: bool = True + + """Whether to persist a logged in session to storage.""" + persist_session: bool = True + + """Detect a session from the URL. Used for OAuth login callbacks.""" + detect_session_in_url: bool = True + + """A storage provider. Used to store the logged in session.""" + local_storage: Dict[str, Any] = dataclasses.field(default_factory=lambda: {}) + + """Options passed to the realtime-js instance""" + realtime: Optional[Dict[str, Any]] = None + + """A custom `fetch` implementation.""" + fetch: Optional[Callable] = None + + def replace( + self, + schema: Optional[str] = None, + headers: Optional[Dict[str, str]] = None, + auto_refresh_token: Optional[bool] = None, + persist_session: Optional[bool] = None, + detect_session_in_url: Optional[bool] = None, + local_storage: Optional[Dict[str, Any]] = None, + realtime: Optional[Dict[str, Any]] = None, + fetch: Optional[Callable] = None, + ) -> "ClientOptions": + """Create a new SupabaseClientOptions with changes""" + changes = { + key: value + for key, value in locals().items() + if key != "self" and value is not None + } + client_options = dataclasses.replace(self, **changes) + client_options = copy.deepcopy(client_options) + return client_options diff --git a/supabase/lib/constants.py b/supabase/lib/constants.py index ebba6e60..af06f7bf 100644 --- a/supabase/lib/constants.py +++ b/supabase/lib/constants.py @@ -1,3 +1,4 @@ -from supabase import __version__ +from supabase.lib.client_options import ClientOptions -DEFAULT_HEADERS = {"X-Client-Info": f"supabase-py/{__version__}"} + +DEFAULT_OPTIONS: ClientOptions = ClientOptions() diff --git a/supabase/lib/storage_client.py b/supabase/lib/storage_client.py index f7737174..06474c1e 100644 --- a/supabase/lib/storage_client.py +++ b/supabase/lib/storage_client.py @@ -1,3 +1,5 @@ +from typing import Dict + from supabase.lib.storage.storage_bucket_api import StorageBucketAPI from supabase.lib.storage.storage_file_api import StorageFileAPI @@ -14,8 +16,8 @@ class SupabaseStorageClient(StorageBucketAPI): >>> list_files = storage_file.list("something") """ - def __init__(self, url, headers): + def __init__(self, url: str, headers: Dict[str, str]): super().__init__(url, headers) - def StorageFileAPI(self, id_): + def StorageFileAPI(self, id_: str): return StorageFileAPI(self.url, self.headers, id_) diff --git a/test.ps1 b/test.ps1 new file mode 100644 index 00000000..11e37137 --- /dev/null +++ b/test.ps1 @@ -0,0 +1,5 @@ +powershell -Command { + $env:SUPABASE_TEST_KEY = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlhdCI6MTYzNTAwODQ4NywiZXhwIjoxOTUwNTg0NDg3fQ.l8IgkO7TQokGSc9OJoobXIVXsOXkilXl4Ak6SCX5qI8"; + $env:SUPABASE_TEST_URL = "https://ibrydvrsxoapzgtnhpso.supabase.co"; + poetry run pytest; +} diff --git a/tests/conftest.py b/tests/conftest.py index 5bc27b32..230684c3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,7 +9,9 @@ @pytest.fixture(scope="session") def supabase() -> Client: - url: str = os.environ.get("SUPABASE_TEST_URL") - key: str = os.environ.get("SUPABASE_TEST_KEY") + url = os.environ.get("SUPABASE_TEST_URL") + assert url is not None, "Must provide SUPABASE_TEST_URL environment variable" + key = os.environ.get("SUPABASE_TEST_KEY") + assert key is not None, "Must provide SUPABASE_TEST_KEY environment variable" supabase: Client = create_client(url, key) return supabase diff --git a/tests/test_client_options.py b/tests/test_client_options.py new file mode 100644 index 00000000..16db3892 --- /dev/null +++ b/tests/test_client_options.py @@ -0,0 +1,39 @@ +from supabase.lib.client_options import ClientOptions + + +def test__client_options__replace__returns_updated_options(): + options = ClientOptions( + schema="schema", + headers={"key": "value"}, + auto_refresh_token=False, + persist_session=False, + detect_session_in_url=False, + local_storage={"key": "value"}, + realtime={"key": "value"} + ) + + actual = options.replace(schema="new schema") + expected = ClientOptions( + schema="new schema", + headers={"key": "value"}, + auto_refresh_token=False, + persist_session=False, + detect_session_in_url=False, + local_storage={"key": "value"}, + realtime={"key": "value"} + ) + + assert actual == expected + + +def test__client_options__replace__updates_only_new_options(): + # Arrange + options = ClientOptions(local_storage={"key": "value"}) + new_options = options.replace() + + # Act + new_options.local_storage["key"] = "new_value" + + # Assert + assert options.local_storage["key"] == "value" + assert new_options.local_storage["key"] == "new_value"