From 0b6b6bdcae0b61dbcd8507db2dd0dbbcfcc56c03 Mon Sep 17 00:00:00 2001 From: Andrew Smith Date: Sun, 27 Apr 2025 02:35:22 +0000 Subject: [PATCH 1/6] wip: dependency injection for httpx --- supabase/_async/client.py | 5 ++++- supabase/_sync/client.py | 5 ++++- supabase/lib/client_options.py | 11 +++++++++++ 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/supabase/_async/client.py b/supabase/_async/client.py index f23c5de2..18937212 100644 --- a/supabase/_async/client.py +++ b/supabase/_async/client.py @@ -5,7 +5,7 @@ from gotrue import AsyncMemoryStorage from gotrue.types import AuthChangeEvent, Session -from httpx import Timeout +from httpx import AsyncClient, Timeout from postgrest import ( AsyncPostgrestClient, ) @@ -174,6 +174,7 @@ def postgrest(self): headers=self.options.headers, schema=self.options.schema, timeout=self.options.postgrest_client_timeout, + client=self.options.httpx_client, ) return self._postgrest @@ -264,6 +265,7 @@ def _init_postgrest_client( timeout: Union[int, float, Timeout] = DEFAULT_POSTGREST_CLIENT_TIMEOUT, verify: bool = True, proxy: Optional[str] = None, + client: Union[AsyncClient, None] = None, ) -> AsyncPostgrestClient: """Private helper for creating an instance of the Postgrest client.""" return AsyncPostgrestClient( @@ -273,6 +275,7 @@ def _init_postgrest_client( timeout=timeout, verify=verify, proxy=proxy, + client=client, ) def _create_auth_header(self, token: str): diff --git a/supabase/_sync/client.py b/supabase/_sync/client.py index bc3fb4ed..7b78ee14 100644 --- a/supabase/_sync/client.py +++ b/supabase/_sync/client.py @@ -4,7 +4,7 @@ from gotrue import SyncMemoryStorage from gotrue.types import AuthChangeEvent, Session -from httpx import Timeout +from httpx import SyncClient, Timeout from postgrest import ( SyncPostgrestClient, ) @@ -173,6 +173,7 @@ def postgrest(self): headers=self.options.headers, schema=self.options.schema, timeout=self.options.postgrest_client_timeout, + client=self.options.httpx_client, ) return self._postgrest @@ -263,6 +264,7 @@ def _init_postgrest_client( timeout: Union[int, float, Timeout] = DEFAULT_POSTGREST_CLIENT_TIMEOUT, verify: bool = True, proxy: Optional[str] = None, + client: Union[SyncClient, None] = None, ) -> SyncPostgrestClient: """Private helper for creating an instance of the Postgrest client.""" return SyncPostgrestClient( @@ -272,6 +274,7 @@ def _init_postgrest_client( timeout=timeout, verify=verify, proxy=proxy, + client=client, ) def _create_auth_header(self, token: str): diff --git a/supabase/lib/client_options.py b/supabase/lib/client_options.py index 47498c13..e9331f6b 100644 --- a/supabase/lib/client_options.py +++ b/supabase/lib/client_options.py @@ -8,6 +8,8 @@ SyncMemoryStorage, SyncSupportedStorage, ) +from httpx import AsyncClient as HttpxAsyncClient +from httpx import Client as HttpxClient from httpx import Timeout from postgrest.constants import DEFAULT_POSTGREST_CLIENT_TIMEOUT from storage3.constants import DEFAULT_TIMEOUT as DEFAULT_STORAGE_CLIENT_TIMEOUT @@ -43,6 +45,9 @@ class ClientOptions: realtime: Optional[RealtimeClientOptions] = None """Options passed to the realtime-py instance""" + httpx_client: Union[HttpxClient] = None + """Options passed to the realtime-py instance""" + postgrest_client_timeout: Union[int, float, Timeout] = ( DEFAULT_POSTGREST_CLIENT_TIMEOUT ) @@ -67,6 +72,7 @@ def replace( persist_session: Optional[bool] = None, storage: Optional[SyncSupportedStorage] = None, realtime: Optional[RealtimeClientOptions] = None, + httpx_client: Optional[HttpxClient] = None, postgrest_client_timeout: Union[ int, float, Timeout ] = DEFAULT_POSTGREST_CLIENT_TIMEOUT, @@ -85,6 +91,7 @@ def replace( client_options.persist_session = persist_session or self.persist_session client_options.storage = storage or self.storage client_options.realtime = realtime or self.realtime + client_options.httpx_client = httpx_client or self.httpx_client client_options.postgrest_client_timeout = ( postgrest_client_timeout or self.postgrest_client_timeout ) @@ -108,6 +115,7 @@ def replace( persist_session: Optional[bool] = None, storage: Optional[AsyncSupportedStorage] = None, realtime: Optional[RealtimeClientOptions] = None, + httpx_client: Optional[HttpxAsyncClient] = None, postgrest_client_timeout: Union[ int, float, Timeout ] = DEFAULT_POSTGREST_CLIENT_TIMEOUT, @@ -126,6 +134,7 @@ def replace( client_options.persist_session = persist_session or self.persist_session client_options.storage = storage or self.storage client_options.realtime = realtime or self.realtime + client_options.httpx_client = httpx_client or self.httpx_client client_options.postgrest_client_timeout = ( postgrest_client_timeout or self.postgrest_client_timeout ) @@ -146,6 +155,7 @@ def replace( persist_session: Optional[bool] = None, storage: Optional[SyncSupportedStorage] = None, realtime: Optional[RealtimeClientOptions] = None, + httpx_client: Optional[HttpxClient] = None, postgrest_client_timeout: Union[ int, float, Timeout ] = DEFAULT_POSTGREST_CLIENT_TIMEOUT, @@ -164,6 +174,7 @@ def replace( client_options.persist_session = persist_session or self.persist_session client_options.storage = storage or self.storage client_options.realtime = realtime or self.realtime + client_options.httpx_client = httpx_client or self.httpx_client client_options.postgrest_client_timeout = ( postgrest_client_timeout or self.postgrest_client_timeout ) From b1338c32e888882a7cfc5087530e91a98d16b359 Mon Sep 17 00:00:00 2001 From: Andrew Smith Date: Sun, 27 Apr 2025 17:00:28 +0000 Subject: [PATCH 2/6] fix: update with correct httpx client class --- supabase/_async/client.py | 5 +++-- supabase/_sync/client.py | 5 +++-- supabase/lib/client_options.py | 12 ++++++------ 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/supabase/_async/client.py b/supabase/_async/client.py index 18937212..5bb959d3 100644 --- a/supabase/_async/client.py +++ b/supabase/_async/client.py @@ -5,7 +5,7 @@ from gotrue import AsyncMemoryStorage from gotrue.types import AuthChangeEvent, Session -from httpx import AsyncClient, Timeout +from httpx import Timeout from postgrest import ( AsyncPostgrestClient, ) @@ -17,6 +17,7 @@ from supafunc import AsyncFunctionsClient from ..lib.client_options import AsyncClientOptions as ClientOptions +from ..lib.client_options import AsyncHttpxClient from .auth_client import AsyncSupabaseAuthClient @@ -265,7 +266,7 @@ def _init_postgrest_client( timeout: Union[int, float, Timeout] = DEFAULT_POSTGREST_CLIENT_TIMEOUT, verify: bool = True, proxy: Optional[str] = None, - client: Union[AsyncClient, None] = None, + client: Union[AsyncHttpxClient, None] = None, ) -> AsyncPostgrestClient: """Private helper for creating an instance of the Postgrest client.""" return AsyncPostgrestClient( diff --git a/supabase/_sync/client.py b/supabase/_sync/client.py index 7b78ee14..0a23fc8f 100644 --- a/supabase/_sync/client.py +++ b/supabase/_sync/client.py @@ -4,7 +4,7 @@ from gotrue import SyncMemoryStorage from gotrue.types import AuthChangeEvent, Session -from httpx import SyncClient, Timeout +from httpx import Timeout from postgrest import ( SyncPostgrestClient, ) @@ -16,6 +16,7 @@ from supafunc import SyncFunctionsClient from ..lib.client_options import SyncClientOptions as ClientOptions +from ..lib.client_options import SyncHttpxClient from .auth_client import SyncSupabaseAuthClient @@ -264,7 +265,7 @@ def _init_postgrest_client( timeout: Union[int, float, Timeout] = DEFAULT_POSTGREST_CLIENT_TIMEOUT, verify: bool = True, proxy: Optional[str] = None, - client: Union[SyncClient, None] = None, + client: Union[SyncHttpxClient, None] = None, ) -> SyncPostgrestClient: """Private helper for creating an instance of the Postgrest client.""" return SyncPostgrestClient( diff --git a/supabase/lib/client_options.py b/supabase/lib/client_options.py index e9331f6b..37c17424 100644 --- a/supabase/lib/client_options.py +++ b/supabase/lib/client_options.py @@ -8,8 +8,8 @@ SyncMemoryStorage, SyncSupportedStorage, ) -from httpx import AsyncClient as HttpxAsyncClient -from httpx import Client as HttpxClient +from httpx import AsyncClient as AsyncHttpxClient +from httpx import Client as SyncHttpxClient from httpx import Timeout from postgrest.constants import DEFAULT_POSTGREST_CLIENT_TIMEOUT from storage3.constants import DEFAULT_TIMEOUT as DEFAULT_STORAGE_CLIENT_TIMEOUT @@ -45,7 +45,7 @@ class ClientOptions: realtime: Optional[RealtimeClientOptions] = None """Options passed to the realtime-py instance""" - httpx_client: Union[HttpxClient] = None + httpx_client: Union[SyncHttpxClient] = None """Options passed to the realtime-py instance""" postgrest_client_timeout: Union[int, float, Timeout] = ( @@ -72,7 +72,7 @@ def replace( persist_session: Optional[bool] = None, storage: Optional[SyncSupportedStorage] = None, realtime: Optional[RealtimeClientOptions] = None, - httpx_client: Optional[HttpxClient] = None, + httpx_client: Optional[SyncHttpxClient] = None, postgrest_client_timeout: Union[ int, float, Timeout ] = DEFAULT_POSTGREST_CLIENT_TIMEOUT, @@ -115,7 +115,7 @@ def replace( persist_session: Optional[bool] = None, storage: Optional[AsyncSupportedStorage] = None, realtime: Optional[RealtimeClientOptions] = None, - httpx_client: Optional[HttpxAsyncClient] = None, + httpx_client: Optional[AsyncHttpxClient] = None, postgrest_client_timeout: Union[ int, float, Timeout ] = DEFAULT_POSTGREST_CLIENT_TIMEOUT, @@ -155,7 +155,7 @@ def replace( persist_session: Optional[bool] = None, storage: Optional[SyncSupportedStorage] = None, realtime: Optional[RealtimeClientOptions] = None, - httpx_client: Optional[HttpxClient] = None, + httpx_client: Optional[SyncHttpxClient] = None, postgrest_client_timeout: Union[ int, float, Timeout ] = DEFAULT_POSTGREST_CLIENT_TIMEOUT, From 862e3bf70237dcf1867468ac7d30258ba0dfbc05 Mon Sep 17 00:00:00 2001 From: Andrew Smith Date: Sun, 22 Jun 2025 00:15:03 +0100 Subject: [PATCH 3/6] test: split client tests between async and sync directory --- tests/_async/__init__.py | 0 tests/_async/test_client.py | 135 +++++++++++++++++++++-- tests/_sync/__init__.py | 0 tests/{ => _sync}/test_client.py | 180 ++++++++++++++++++------------- 4 files changed, 232 insertions(+), 83 deletions(-) create mode 100644 tests/_async/__init__.py create mode 100644 tests/_sync/__init__.py rename tests/{ => _sync}/test_client.py (57%) diff --git a/tests/_async/__init__.py b/tests/_async/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/_async/test_client.py b/tests/_async/test_client.py index 6e8ba794..3845aadd 100644 --- a/tests/_async/test_client.py +++ b/tests/_async/test_client.py @@ -1,21 +1,37 @@ import os +from typing import Any from unittest.mock import AsyncMock, MagicMock -from supabase import AClient, ASupabaseException, create_async_client - - -async def test_incorrect_values_dont_instantiate_client() -> None: +import pytest +from gotrue import AsyncMemoryStorage +from httpx import AsyncClient as AsyncHttpxClient +from httpx import AsyncHTTPTransport, Limits, Timeout + +from supabase import ( + AsyncClient, + AsyncClientOptions, + AsyncSupabaseException, + create_async_client, +) + + +@pytest.mark.xfail( + reason="None of these values should be able to instantiate a client object" +) +@pytest.mark.parametrize("url", ["", None, "valeefgpoqwjgpj", 139, -1, {}, []]) +@pytest.mark.parametrize("key", ["", None, "valeefgpoqwjgpj", 139, -1, {}, []]) +async def test_incorrect_values_dont_instantiate_client(url: Any, key: Any) -> None: """Ensure we can't instantiate client with invalid values.""" try: - client: AClient = await create_async_client(None, None) - except ASupabaseException: + _: AsyncClient = await create_async_client(url, key) + except AsyncSupabaseException: pass async def test_supabase_exception() -> None: try: - raise ASupabaseException("err") - except ASupabaseException: + raise AsyncSupabaseException("err") + except AsyncSupabaseException: pass @@ -25,6 +41,7 @@ async def test_postgrest_client() -> None: client = await create_async_client(url, key) assert client.table("sample") + assert client.postgrest.schema("new_schema") async def test_rpc_client() -> None: @@ -43,6 +60,25 @@ async def test_function_initialization() -> None: assert client.functions +async def test_uses_key_as_authorization_header_by_default() -> None: + url = os.environ.get("SUPABASE_TEST_URL") + key = os.environ.get("SUPABASE_TEST_KEY") + + client = await create_async_client(url, key) + + assert client.options.headers.get("apiKey") == key + assert client.options.headers.get("Authorization") == f"Bearer {key}" + + assert client.postgrest.session.headers.get("apiKey") == key + assert client.postgrest.session.headers.get("Authorization") == f"Bearer {key}" + + assert client.auth._headers.get("apiKey") == key + assert client.auth._headers.get("Authorization") == f"Bearer {key}" + + assert client.storage.session.headers.get("apiKey") == key + assert client.storage.session.headers.get("Authorization") == f"Bearer {key}" + + async def test_schema_update() -> None: url = os.environ.get("SUPABASE_TEST_URL") key = os.environ.get("SUPABASE_TEST_KEY") @@ -83,4 +119,85 @@ async def test_updates_the_authorization_header_on_auth_events() -> None: assert client.storage.session.headers.get("apiKey") == key assert client.storage.session.headers.get("Authorization") == updated_authorization - realtime_mock.set_auth.assert_called_once_with(mock_session.access_token) + +async def test_supports_setting_a_global_authorization_header() -> None: + url = os.environ.get("SUPABASE_TEST_URL") + key = os.environ.get("SUPABASE_TEST_KEY") + + authorization = "Bearer secretuserjwt" + + options = AsyncClientOptions(headers={"Authorization": authorization}) + + client = await create_async_client(url, key, options) + + assert client.options.headers.get("apiKey") == key + assert client.options.headers.get("Authorization") == authorization + + assert client.postgrest.session.headers.get("apiKey") == key + assert client.postgrest.session.headers.get("Authorization") == authorization + + assert client.auth._headers.get("apiKey") == key + assert client.auth._headers.get("Authorization") == authorization + + assert client.storage.session.headers.get("apiKey") == key + assert client.storage.session.headers.get("Authorization") == authorization + + +async def test_mutable_headers_issue(): + url = os.environ.get("SUPABASE_TEST_URL") + key = os.environ.get("SUPABASE_TEST_KEY") + + shared_options = AsyncClientOptions( + storage=AsyncMemoryStorage(), headers={"Authorization": "Bearer initial-token"} + ) + + client1 = await create_async_client(url, key, shared_options) + client2 = await create_async_client(url, key, shared_options) + + client1.options.headers["Authorization"] = "Bearer modified-token" + + assert client2.options.headers["Authorization"] == "Bearer initial-token" + assert client1.options.headers["Authorization"] == "Bearer modified-token" + + +async def test_global_authorization_header_issue(): + url = os.environ.get("SUPABASE_TEST_URL") + key = os.environ.get("SUPABASE_TEST_KEY") + + authorization = "Bearer secretuserjwt" + options = AsyncClientOptions(headers={"Authorization": authorization}) + + client = await create_async_client(url, key, options) + + assert client.options.headers.get("apiKey") == key + + +async def test_httpx_client(): + url = os.environ.get("SUPABASE_TEST_URL") + key = os.environ.get("SUPABASE_TEST_KEY") + + transport = AsyncHTTPTransport( + retries=10, + verify=False, + limits=Limits( + max_connections=1, + ), + ) + + headers = {"x-user-agent": "my-app/0.0.1"} + async with AsyncHttpxClient( + transport=transport, headers=headers, timeout=Timeout(2.0) + ) as http_client: + # Create a client with the custom httpx client + options = AsyncClientOptions(httpx_client=http_client) + + client = await create_async_client(url, key, options) + + assert client.postgrest.session.headers.get("x-user-agent") == "my-app/0.0.1" + assert client.auth._http_client.headers.get("x-user-agent") == "my-app/0.0.1" + assert client.storage.session.headers.get("x-user-agent") == "my-app/0.0.1" + assert client.functions._client.headers.get("x-user-agent") == "my-app/0.0.1" + assert client.postgrest.session.timeout == Timeout(2.0) + assert client.auth._http_client.timeout == Timeout(2.0) + assert client.storage.session.timeout == Timeout(2.0) + assert client.functions._client.timeout == Timeout(2.0) diff --git a/tests/_sync/__init__.py b/tests/_sync/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_client.py b/tests/_sync/test_client.py similarity index 57% rename from tests/test_client.py rename to tests/_sync/test_client.py index 0768a010..a9d0922b 100644 --- a/tests/test_client.py +++ b/tests/_sync/test_client.py @@ -1,43 +1,71 @@ -from __future__ import annotations - import os -from unittest.mock import MagicMock +from typing import Any +from unittest.mock import MagicMock, SyncMock +import pytest from gotrue import SyncMemoryStorage - -from supabase import ClientOptions, create_client - - -def test_function_initialization() -> None: +from httpx import Limits +from httpx import SyncClient as SyncHttpxClient +from httpx import SyncHTTPTransport, Timeout + +from supabase import ( + SyncClient, + SyncClientOptions, + SyncSupabaseException, + create_async_client, +) + + +@pytest.mark.xfail( + reason="None of these values should be able to instantiate a client object" +) +@pytest.mark.parametrize("url", ["", None, "valeefgpoqwjgpj", 139, -1, {}, []]) +@pytest.mark.parametrize("key", ["", None, "valeefgpoqwjgpj", 139, -1, {}, []]) +def test_incorrect_values_dont_instantiate_client(url: Any, key: Any) -> None: + """Ensure we can't instantiate client with invalid values.""" + try: + _: SyncClient = create_async_client(url, key) + except SyncSupabaseException: + pass + + +def test_supabase_exception() -> None: + try: + raise SyncSupabaseException("err") + except SyncSupabaseException: + pass + + +def test_postgrest_client() -> None: url = os.environ.get("SUPABASE_TEST_URL") key = os.environ.get("SUPABASE_TEST_KEY") - client = create_client(url, key) - assert client.functions + client = create_async_client(url, key) + assert client.table("sample") + assert client.postgrest.schema("new_schema") -def test_postgrest_schema() -> None: +def test_rpc_client() -> None: url = os.environ.get("SUPABASE_TEST_URL") key = os.environ.get("SUPABASE_TEST_KEY") - client = create_client(url, key) - assert client.postgrest - assert client.postgrest.schema("new_schema") + client = create_async_client(url, key) + assert client.rpc("test_fn") -def test_rpc_client() -> None: +def test_function_initialization() -> None: url = os.environ.get("SUPABASE_TEST_URL") key = os.environ.get("SUPABASE_TEST_KEY") - client = create_client(url, key) - assert client.rpc("test_fn") + client = create_async_client(url, key) + assert client.functions def test_uses_key_as_authorization_header_by_default() -> None: url = os.environ.get("SUPABASE_TEST_URL") key = os.environ.get("SUPABASE_TEST_KEY") - client = create_client(url, key) + client = create_async_client(url, key) assert client.options.headers.get("apiKey") == key assert client.options.headers.get("Authorization") == f"Bearer {key}" @@ -52,41 +80,28 @@ def test_uses_key_as_authorization_header_by_default() -> None: assert client.storage.session.headers.get("Authorization") == f"Bearer {key}" -def test_supports_setting_a_global_authorization_header() -> None: +def test_schema_update() -> None: url = os.environ.get("SUPABASE_TEST_URL") key = os.environ.get("SUPABASE_TEST_KEY") - authorization = "Bearer secretuserjwt" - - options = ClientOptions(headers={"Authorization": authorization}) - - client = create_client(url, key, options) - - assert client.options.headers.get("apiKey") == key - assert client.options.headers.get("Authorization") == authorization - - assert client.postgrest.session.headers.get("apiKey") == key - assert client.postgrest.session.headers.get("Authorization") == authorization - - assert client.auth._headers.get("apiKey") == key - assert client.auth._headers.get("Authorization") == authorization - - assert client.storage.session.headers.get("apiKey") == key - assert client.storage.session.headers.get("Authorization") == authorization + client = create_async_client(url, key) + assert client.postgrest + assert client.schema("new_schema") def test_updates_the_authorization_header_on_auth_events() -> None: url = os.environ.get("SUPABASE_TEST_URL") key = os.environ.get("SUPABASE_TEST_KEY") - client = create_client(url, key) + client = create_async_client(url, key) assert client.options.headers.get("apiKey") == key assert client.options.headers.get("Authorization") == f"Bearer {key}" mock_session = MagicMock(access_token="secretuserjwt") - realtime_mock = MagicMock() + realtime_mock = SyncMock() client.realtime = realtime_mock + client._listen_to_auth_events("SIGNED_IN", mock_session) updated_authorization = f"Bearer {mock_session.access_token}" @@ -98,6 +113,7 @@ def test_updates_the_authorization_header_on_auth_events() -> None: assert ( client.postgrest.session.headers.get("Authorization") == updated_authorization ) + assert client.auth._headers.get("apiKey") == key assert client.auth._headers.get("Authorization") == updated_authorization @@ -105,16 +121,39 @@ def test_updates_the_authorization_header_on_auth_events() -> None: assert client.storage.session.headers.get("Authorization") == updated_authorization +def test_supports_setting_a_global_authorization_header() -> None: + url = os.environ.get("SUPABASE_TEST_URL") + key = os.environ.get("SUPABASE_TEST_KEY") + + authorization = "Bearer secretuserjwt" + + options = SyncClientOptions(headers={"Authorization": authorization}) + + client = create_async_client(url, key, options) + + assert client.options.headers.get("apiKey") == key + assert client.options.headers.get("Authorization") == authorization + + assert client.postgrest.session.headers.get("apiKey") == key + assert client.postgrest.session.headers.get("Authorization") == authorization + + assert client.auth._headers.get("apiKey") == key + assert client.auth._headers.get("Authorization") == authorization + + assert client.storage.session.headers.get("apiKey") == key + assert client.storage.session.headers.get("Authorization") == authorization + + def test_mutable_headers_issue(): url = os.environ.get("SUPABASE_TEST_URL") key = os.environ.get("SUPABASE_TEST_KEY") - shared_options = ClientOptions( + shared_options = SyncClientOptions( storage=SyncMemoryStorage(), headers={"Authorization": "Bearer initial-token"} ) - client1 = create_client(url, key, shared_options) - client2 = create_client(url, key, shared_options) + client1 = create_async_client(url, key, shared_options) + client2 = create_async_client(url, key, shared_options) client1.options.headers["Authorization"] = "Bearer modified-token" @@ -127,46 +166,39 @@ def test_global_authorization_header_issue(): key = os.environ.get("SUPABASE_TEST_KEY") authorization = "Bearer secretuserjwt" - options = ClientOptions(headers={"Authorization": authorization}) + options = SyncClientOptions(headers={"Authorization": authorization}) - client = create_client(url, key, options) + client = create_async_client(url, key, options) assert client.options.headers.get("apiKey") == key -def test_custom_headers(): - url = os.environ.get("SUPABASE_TEST_URL") - key = os.environ.get("SUPABASE_TEST_KEY") - - options = ClientOptions( - headers={ - "x-app-name": "apple", - "x-version": "1.0", - } - ) - - client = create_client(url, key, options) - - assert client.options.headers.get("x-app-name") == "apple" - assert client.options.headers.get("x-version") == "1.0" - - -def test_custom_headers_immutable(): +def test_httpx_client(): url = os.environ.get("SUPABASE_TEST_URL") key = os.environ.get("SUPABASE_TEST_KEY") - options = ClientOptions( - headers={ - "x-app-name": "apple", - "x-version": "1.0", - } + transport = SyncHTTPTransport( + retries=10, + verify=False, + limits=Limits( + max_connections=1, + ), ) - client1 = create_client(url, key, options) - client2 = create_client(url, key, options) - - client1.options.headers["x-app-name"] = "grapes" - - assert client1.options.headers.get("x-app-name") == "grapes" - assert client1.options.headers.get("x-version") == "1.0" - assert client2.options.headers.get("x-app-name") == "apple" + headers = {"x-user-agent": "my-app/0.0.1"} + with SyncHttpxClient( + transport=transport, headers=headers, timeout=Timeout(2.0) + ) as http_client: + # Create a client with the custom httpx client + options = SyncClientOptions(httpx_client=http_client) + + client = create_async_client(url, key, options) + + assert client.postgrest.session.headers.get("x-user-agent") == "my-app/0.0.1" + assert client.auth._http_client.headers.get("x-user-agent") == "my-app/0.0.1" + assert client.storage.session.headers.get("x-user-agent") == "my-app/0.0.1" + assert client.functions._client.headers.get("x-user-agent") == "my-app/0.0.1" + assert client.postgrest.session.timeout == Timeout(2.0) + assert client.auth._http_client.timeout == Timeout(2.0) + assert client.storage.session.timeout == Timeout(2.0) + assert client.functions._client.timeout == Timeout(2.0) From f2bd541e4268b0457ee6859cbaea91c3c3664929 Mon Sep 17 00:00:00 2001 From: Andrew Smith Date: Sun, 22 Jun 2025 00:15:58 +0100 Subject: [PATCH 4/6] chore: update make file for build steps --- Makefile | 4 ++++ supabase/__init__.py | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/Makefile b/Makefile index ab5214c2..f611c8ee 100644 --- a/Makefile +++ b/Makefile @@ -21,3 +21,7 @@ build_sync: sed -i 's/asynch/synch/g' supabase/_sync/auth_client.py sed -i 's/Async/Sync/g' supabase/_sync/auth_client.py sed -i 's/Async/Sync/g' supabase/_sync/client.py + sed -i 's/create_async_client/create_client/g' tests/_sync/test_client.py + sed -i 's/SyncClient/Client/gi' tests/_sync/test_client.py + sed -i 's/SyncHTTPTransport/HTTPTransport/g' tests/_sync/test_client.py + sed -i 's/SyncMock/Mock/g' tests/_sync/test_client.py diff --git a/supabase/__init__.py b/supabase/__init__.py index adf895a2..75ba190d 100644 --- a/supabase/__init__.py +++ b/supabase/__init__.py @@ -20,12 +20,14 @@ from ._async.client import AsyncClient as AClient from ._async.client import AsyncStorageClient as ASupabaseStorageClient from ._async.client import SupabaseException as ASupabaseException +from ._async.client import SupabaseException as AsyncSupabaseException from ._async.client import create_client as acreate_client from ._async.client import create_client as create_async_client # Sync Client from ._sync.auth_client import SyncSupabaseAuthClient as SupabaseAuthClient from ._sync.client import SupabaseException +from ._sync.client import SupabaseException as SyncSupabaseException from ._sync.client import SyncClient as Client from ._sync.client import SyncStorageClient as SupabaseStorageClient from ._sync.client import create_client @@ -71,4 +73,6 @@ "NotConnectedError", "SupabaseException", "ASupabaseException", + "AsyncSupabaseException", + "SyncSupabaseException", ) From 31b1729714d699d894585b19499d10e8506582f2 Mon Sep 17 00:00:00 2001 From: Andrew Smith Date: Sun, 22 Jun 2025 00:20:02 +0100 Subject: [PATCH 5/6] fix: use deprecated values if no http client instance is provided --- supabase/_async/client.py | 49 +++++++++++++++++++++++++++------- supabase/_sync/client.py | 49 +++++++++++++++++++++++++++------- supabase/lib/client_options.py | 7 +++-- 3 files changed, 83 insertions(+), 22 deletions(-) diff --git a/supabase/_async/client.py b/supabase/_async/client.py index 5bb959d3..b60d6ad8 100644 --- a/supabase/_async/client.py +++ b/supabase/_async/client.py @@ -175,7 +175,7 @@ def postgrest(self): headers=self.options.headers, schema=self.options.schema, timeout=self.options.postgrest_client_timeout, - client=self.options.httpx_client, + http_client=self.options.httpx_client, ) return self._postgrest @@ -187,6 +187,7 @@ def storage(self): storage_url=self.storage_url, headers=self.options.headers, storage_client_timeout=self.options.storage_client_timeout, + http_client=self.options.httpx_client, ) return self._storage @@ -194,9 +195,14 @@ def storage(self): def functions(self): if self._functions is None: self._functions = AsyncFunctionsClient( - self.functions_url, - self.options.headers, - self.options.function_client_timeout, + url=self.functions_url, + headers=self.options.headers, + timeout=( + self.options.function_client_timeout + if self.options.httpx_client is None + else None + ), + http_client=self.options.httpx_client, ) return self._functions @@ -234,9 +240,23 @@ def _init_storage_client( storage_client_timeout: int = DEFAULT_STORAGE_CLIENT_TIMEOUT, verify: bool = True, proxy: Optional[str] = None, + http_client: Union[AsyncHttpxClient, None] = None, ) -> AsyncStorageClient: + if http_client is not None: + # If an http client is provided, use it + kwargs = {"http_client": http_client} + else: + kwargs = { + "timeout": storage_client_timeout, + "verify": verify, + "proxy": proxy, + "http_client": None, + } + return AsyncStorageClient( - storage_url, headers, storage_client_timeout, verify, proxy + url=storage_url, + headers=headers, + **kwargs, ) @staticmethod @@ -256,6 +276,7 @@ def _init_supabase_auth_client( flow_type=client_options.flow_type, verify=verify, proxy=proxy, + http_client=client_options.httpx_client, ) @staticmethod @@ -266,17 +287,25 @@ def _init_postgrest_client( timeout: Union[int, float, Timeout] = DEFAULT_POSTGREST_CLIENT_TIMEOUT, verify: bool = True, proxy: Optional[str] = None, - client: Union[AsyncHttpxClient, None] = None, + http_client: Union[AsyncHttpxClient, None] = None, ) -> AsyncPostgrestClient: """Private helper for creating an instance of the Postgrest client.""" + if http_client is not None: + # If an http client is provided, use it + kwargs = {"http_client": http_client} + else: + kwargs = { + "timeout": timeout, + "verify": verify, + "proxy": proxy, + "http_client": None, + } + return AsyncPostgrestClient( rest_url, headers=headers, schema=schema, - timeout=timeout, - verify=verify, - proxy=proxy, - client=client, + **kwargs, ) def _create_auth_header(self, token: str): diff --git a/supabase/_sync/client.py b/supabase/_sync/client.py index 0a23fc8f..7a5f2250 100644 --- a/supabase/_sync/client.py +++ b/supabase/_sync/client.py @@ -174,7 +174,7 @@ def postgrest(self): headers=self.options.headers, schema=self.options.schema, timeout=self.options.postgrest_client_timeout, - client=self.options.httpx_client, + http_client=self.options.httpx_client, ) return self._postgrest @@ -186,6 +186,7 @@ def storage(self): storage_url=self.storage_url, headers=self.options.headers, storage_client_timeout=self.options.storage_client_timeout, + http_client=self.options.httpx_client, ) return self._storage @@ -193,9 +194,14 @@ def storage(self): def functions(self): if self._functions is None: self._functions = SyncFunctionsClient( - self.functions_url, - self.options.headers, - self.options.function_client_timeout, + url=self.functions_url, + headers=self.options.headers, + timeout=( + self.options.function_client_timeout + if self.options.httpx_client is None + else None + ), + http_client=self.options.httpx_client, ) return self._functions @@ -233,9 +239,23 @@ def _init_storage_client( storage_client_timeout: int = DEFAULT_STORAGE_CLIENT_TIMEOUT, verify: bool = True, proxy: Optional[str] = None, + http_client: Union[SyncHttpxClient, None] = None, ) -> SyncStorageClient: + if http_client is not None: + # If an http client is provided, use it + kwargs = {"http_client": http_client} + else: + kwargs = { + "timeout": storage_client_timeout, + "verify": verify, + "proxy": proxy, + "http_client": None, + } + return SyncStorageClient( - storage_url, headers, storage_client_timeout, verify, proxy + url=storage_url, + headers=headers, + **kwargs, ) @staticmethod @@ -255,6 +275,7 @@ def _init_supabase_auth_client( flow_type=client_options.flow_type, verify=verify, proxy=proxy, + http_client=client_options.httpx_client, ) @staticmethod @@ -265,17 +286,25 @@ def _init_postgrest_client( timeout: Union[int, float, Timeout] = DEFAULT_POSTGREST_CLIENT_TIMEOUT, verify: bool = True, proxy: Optional[str] = None, - client: Union[SyncHttpxClient, None] = None, + http_client: Union[SyncHttpxClient, None] = None, ) -> SyncPostgrestClient: """Private helper for creating an instance of the Postgrest client.""" + if http_client is not None: + # If an http client is provided, use it + kwargs = {"http_client": http_client} + else: + kwargs = { + "timeout": timeout, + "verify": verify, + "proxy": proxy, + "http_client": None, + } + return SyncPostgrestClient( rest_url, headers=headers, schema=schema, - timeout=timeout, - verify=verify, - proxy=proxy, - client=client, + **kwargs, ) def _create_auth_header(self, token: str): diff --git a/supabase/lib/client_options.py b/supabase/lib/client_options.py index 37c17424..ef01468b 100644 --- a/supabase/lib/client_options.py +++ b/supabase/lib/client_options.py @@ -45,8 +45,8 @@ class ClientOptions: realtime: Optional[RealtimeClientOptions] = None """Options passed to the realtime-py instance""" - httpx_client: Union[SyncHttpxClient] = None - """Options passed to the realtime-py instance""" + httpx_client: Optional[SyncHttpxClient] = None + """httpx client instance to be used by the PostgREST, functions, auth and storage clients.""" postgrest_client_timeout: Union[int, float, Timeout] = ( DEFAULT_POSTGREST_CLIENT_TIMEOUT @@ -107,6 +107,9 @@ class AsyncClientOptions(ClientOptions): storage: AsyncSupportedStorage = field(default_factory=AsyncMemoryStorage) """A storage provider. Used to store the logged in session.""" + httpx_client: Optional[AsyncHttpxClient] = None + """httpx client instance to be used by the PostgREST, functions, auth and storage clients.""" + def replace( self, schema: Optional[str] = None, From 99361baa9e86d0a24006ded2860ae4a6269174fc Mon Sep 17 00:00:00 2001 From: Andrew Smith Date: Mon, 23 Jun 2025 16:38:01 +0100 Subject: [PATCH 6/6] test: move custom headers tests to new test files --- tests/_async/test_client.py | 38 ++++++++++++++++ tests/_sync/test_client.py | 87 ++++++++++++++++++++++++++----------- 2 files changed, 100 insertions(+), 25 deletions(-) diff --git a/tests/_async/test_client.py b/tests/_async/test_client.py index 3845aadd..f4f0680d 100644 --- a/tests/_async/test_client.py +++ b/tests/_async/test_client.py @@ -201,3 +201,41 @@ async def test_httpx_client(): assert client.auth._http_client.timeout == Timeout(2.0) assert client.storage.session.timeout == Timeout(2.0) assert client.functions._client.timeout == Timeout(2.0) + + +async def test_custom_headers(): + url = os.environ.get("SUPABASE_TEST_URL") + key = os.environ.get("SUPABASE_TEST_KEY") + + options = AsyncClientOptions( + headers={ + "x-app-name": "apple", + "x-version": "1.0", + } + ) + + client = await create_async_client(url, key, options) + + assert client.options.headers.get("x-app-name") == "apple" + assert client.options.headers.get("x-version") == "1.0" + + +async def test_custom_headers_immutable(): + url = os.environ.get("SUPABASE_TEST_URL") + key = os.environ.get("SUPABASE_TEST_KEY") + + options = AsyncClientOptions( + headers={ + "x-app-name": "apple", + "x-version": "1.0", + } + ) + + client1 = await create_async_client(url, key, options) + client2 = await create_async_client(url, key, options) + + client1.options.headers["x-app-name"] = "grapes" + + assert client1.options.headers.get("x-app-name") == "grapes" + assert client1.options.headers.get("x-version") == "1.0" + assert client2.options.headers.get("x-app-name") == "apple" diff --git a/tests/_sync/test_client.py b/tests/_sync/test_client.py index a9d0922b..53ee5e3d 100644 --- a/tests/_sync/test_client.py +++ b/tests/_sync/test_client.py @@ -1,18 +1,17 @@ import os from typing import Any -from unittest.mock import MagicMock, SyncMock +from unittest.mock import MagicMock, Mock import pytest from gotrue import SyncMemoryStorage -from httpx import Limits -from httpx import SyncClient as SyncHttpxClient -from httpx import SyncHTTPTransport, Timeout +from httpx import Client as SyncHttpxClient +from httpx import HTTPTransport, Limits, Timeout from supabase import ( - SyncClient, - SyncClientOptions, + Client, + ClientOptions, SyncSupabaseException, - create_async_client, + create_client, ) @@ -24,7 +23,7 @@ def test_incorrect_values_dont_instantiate_client(url: Any, key: Any) -> None: """Ensure we can't instantiate client with invalid values.""" try: - _: SyncClient = create_async_client(url, key) + _: Client = create_client(url, key) except SyncSupabaseException: pass @@ -40,7 +39,7 @@ def test_postgrest_client() -> None: url = os.environ.get("SUPABASE_TEST_URL") key = os.environ.get("SUPABASE_TEST_KEY") - client = create_async_client(url, key) + client = create_client(url, key) assert client.table("sample") assert client.postgrest.schema("new_schema") @@ -49,7 +48,7 @@ def test_rpc_client() -> None: url = os.environ.get("SUPABASE_TEST_URL") key = os.environ.get("SUPABASE_TEST_KEY") - client = create_async_client(url, key) + client = create_client(url, key) assert client.rpc("test_fn") @@ -57,7 +56,7 @@ def test_function_initialization() -> None: url = os.environ.get("SUPABASE_TEST_URL") key = os.environ.get("SUPABASE_TEST_KEY") - client = create_async_client(url, key) + client = create_client(url, key) assert client.functions @@ -65,7 +64,7 @@ def test_uses_key_as_authorization_header_by_default() -> None: url = os.environ.get("SUPABASE_TEST_URL") key = os.environ.get("SUPABASE_TEST_KEY") - client = create_async_client(url, key) + client = create_client(url, key) assert client.options.headers.get("apiKey") == key assert client.options.headers.get("Authorization") == f"Bearer {key}" @@ -84,7 +83,7 @@ def test_schema_update() -> None: url = os.environ.get("SUPABASE_TEST_URL") key = os.environ.get("SUPABASE_TEST_KEY") - client = create_async_client(url, key) + client = create_client(url, key) assert client.postgrest assert client.schema("new_schema") @@ -93,13 +92,13 @@ def test_updates_the_authorization_header_on_auth_events() -> None: url = os.environ.get("SUPABASE_TEST_URL") key = os.environ.get("SUPABASE_TEST_KEY") - client = create_async_client(url, key) + client = create_client(url, key) assert client.options.headers.get("apiKey") == key assert client.options.headers.get("Authorization") == f"Bearer {key}" mock_session = MagicMock(access_token="secretuserjwt") - realtime_mock = SyncMock() + realtime_mock = Mock() client.realtime = realtime_mock client._listen_to_auth_events("SIGNED_IN", mock_session) @@ -127,9 +126,9 @@ def test_supports_setting_a_global_authorization_header() -> None: authorization = "Bearer secretuserjwt" - options = SyncClientOptions(headers={"Authorization": authorization}) + options = ClientOptions(headers={"Authorization": authorization}) - client = create_async_client(url, key, options) + client = create_client(url, key, options) assert client.options.headers.get("apiKey") == key assert client.options.headers.get("Authorization") == authorization @@ -148,12 +147,12 @@ def test_mutable_headers_issue(): url = os.environ.get("SUPABASE_TEST_URL") key = os.environ.get("SUPABASE_TEST_KEY") - shared_options = SyncClientOptions( + shared_options = ClientOptions( storage=SyncMemoryStorage(), headers={"Authorization": "Bearer initial-token"} ) - client1 = create_async_client(url, key, shared_options) - client2 = create_async_client(url, key, shared_options) + client1 = create_client(url, key, shared_options) + client2 = create_client(url, key, shared_options) client1.options.headers["Authorization"] = "Bearer modified-token" @@ -166,9 +165,9 @@ def test_global_authorization_header_issue(): key = os.environ.get("SUPABASE_TEST_KEY") authorization = "Bearer secretuserjwt" - options = SyncClientOptions(headers={"Authorization": authorization}) + options = ClientOptions(headers={"Authorization": authorization}) - client = create_async_client(url, key, options) + client = create_client(url, key, options) assert client.options.headers.get("apiKey") == key @@ -177,7 +176,7 @@ def test_httpx_client(): url = os.environ.get("SUPABASE_TEST_URL") key = os.environ.get("SUPABASE_TEST_KEY") - transport = SyncHTTPTransport( + transport = HTTPTransport( retries=10, verify=False, limits=Limits( @@ -190,9 +189,9 @@ def test_httpx_client(): transport=transport, headers=headers, timeout=Timeout(2.0) ) as http_client: # Create a client with the custom httpx client - options = SyncClientOptions(httpx_client=http_client) + options = ClientOptions(httpx_client=http_client) - client = create_async_client(url, key, options) + client = create_client(url, key, options) assert client.postgrest.session.headers.get("x-user-agent") == "my-app/0.0.1" assert client.auth._http_client.headers.get("x-user-agent") == "my-app/0.0.1" @@ -202,3 +201,41 @@ def test_httpx_client(): assert client.auth._http_client.timeout == Timeout(2.0) assert client.storage.session.timeout == Timeout(2.0) assert client.functions._client.timeout == Timeout(2.0) + + +def test_custom_headers(): + url = os.environ.get("SUPABASE_TEST_URL") + key = os.environ.get("SUPABASE_TEST_KEY") + + options = ClientOptions( + headers={ + "x-app-name": "apple", + "x-version": "1.0", + } + ) + + client = create_client(url, key, options) + + assert client.options.headers.get("x-app-name") == "apple" + assert client.options.headers.get("x-version") == "1.0" + + +def test_custom_headers_immutable(): + url = os.environ.get("SUPABASE_TEST_URL") + key = os.environ.get("SUPABASE_TEST_KEY") + + options = ClientOptions( + headers={ + "x-app-name": "apple", + "x-version": "1.0", + } + ) + + client1 = create_client(url, key, options) + client2 = create_client(url, key, options) + + client1.options.headers["x-app-name"] = "grapes" + + assert client1.options.headers.get("x-app-name") == "grapes" + assert client1.options.headers.get("x-version") == "1.0" + assert client2.options.headers.get("x-app-name") == "apple"