diff --git a/.gitignore b/.gitignore index 7114a35b..35f915c1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,25 @@ +tags +## Vim stuff +# Swap +[._]*.s[a-v][a-z] +!*.svg # comment out if you don't need vector files +[._]*.sw[a-p] +[._]s[a-rt-v][a-z] +[._]ss[a-gi-z] +[._]sw[a-p] + +# Session +Session.vim +Sessionx.vim + +# Temporary +.netrwhist +*~ +# Auto-generated tag files +tags +# Persistent undo +[._]*.un~ + # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] diff --git a/README.md b/README.md index c941a821..a6c26458 100644 --- a/README.md +++ b/README.md @@ -4,35 +4,66 @@ Supabase client for Python. This mirrors the design of [supabase-js](https://github.com/supabase/supabase-js/blob/master/README.md) -## Usage +## Installation -`pip3 install supabase` +**Recomended:** First activate your virtual environment, with your favourites system. For example, we like `poetry` and `conda`! +#### PyPi installation +Now install the package. +```bash +pip install supabase +``` +#### Local installation +You can also installing from after cloning this repo. Install like below to install in Development Mode, which means when you edit the source code the changes will be reflected in your python module. +```bash +pip install -e . +``` + +## Usage +It's usually best practice to set your api key environment variables in some way that version control doesn't track them, e.g don't put them in your python modules! Set the key and url for the supabase instance in the shell, or better yet, use a dotenv file. Heres how to set the variables in the shell. +```bash +export SUPABASE_URL="my-url-to-my-awesome-supabase-instance" +export SUPABASE_KEY="my-supa-dupa-secret-supabase-api-key" ``` -import supabase_py -supabaseUrl="" -supabaseKey="" -supabase = supabase_py.Client(supabaseUrl, supabaseKey) + +We can then read the keys in the python source code. +```python +import os +from supabase_py import create_client, Client + +url: str = os.environ.get("SUPABASE_URL") +key: str = os.environ.get("SUPABASE_KEY") +email = "abcdde@gmail.com" +password = "password" +supabase: Client = create_client(url, key) +user = supabase.auth.sign_up(email, password) ``` -### Run tests -`python3 -m pytest` +### Running Tests +Currently the test suites are in a state of flux. We are expanding our clients tests to ensure things are working, and for now can connect to this test instance, that is populated with the following table: +
+ +
+ +The above test database is a blank supabase instance that has populated the `countries` table with the built in countries script that can be found in the supabase UI. You can launch the test scripts and point to the above test database with the +```bash +SUPABASE_TEST_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlhdCI6MTYxMjYwOTMyMiwiZXhwIjoxOTI4MTg1MzIyfQ.XL9W5I_VRQ4iyQHVQmjG0BkwRfx6eVyYB3uAKcesukg" \ +SUPABASE_TEST_URL="https://tfsatoopsijgjhrqplra.supabase.co" \ +pytest -x +``` ### See issues for what to work on - Rough roadmap: -- [] Wrap [Postgrest-py](https://github.com/supabase/postgrest-py/) -- [] Wrap [Realtime-py](https://github.com/supabase/realtime-py) +- [ ] Wrap [Postgrest-py](https://github.com/supabase/postgrest-py/) +- [ ] Wrap [Realtime-py](https://github.com/supabase/realtime-py) - [x] Wrap [Gotrue-py](https://github.com/J0/gotrue-py) ### Client Library - This is a sample of how you'd use [supabase-py]. Functions and tests are WIP - ## Authenticate ``` supabase.auth.signUp({ @@ -79,4 +110,4 @@ mySubscription = supabase .on('*', lambda x: print(x)) .subscribe() ``` -See [Supabase Docs](https://supabase.io/docs/guides/client-libraries) for full list of examples \ No newline at end of file +See [Supabase Docs](https://supabase.io/docs/guides/client-libraries) for full list of examples diff --git a/pyproject.toml b/pyproject.toml index 9ef9447d..d73e2983 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,10 +10,14 @@ python = "^3.7.1" postgrest-py = "^0.3.2" realtime-py="^0.1.0" gotrue="0.1.2" - +pytest="6.2.2" +supabase-realtime-py="0.1.1a0" [tool.poetry.dev-dependencies] [build-system] -requires = ["poetry>=0.12"] +requires = [ + "poetry>=0.12", + "setuptools>=30.3.0,<50", +] build-backend = "poetry.masonry.api" diff --git a/setup.py b/setup.py new file mode 100644 index 00000000..bac24a43 --- /dev/null +++ b/setup.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python + +import setuptools + +if __name__ == "__main__": + setuptools.setup() diff --git a/supabase_py/__init__.py b/supabase_py/__init__.py index 7bc294f6..2ac32a05 100644 --- a/supabase_py/__init__.py +++ b/supabase_py/__init__.py @@ -1,2 +1,9 @@ -from .lib import * -from .client import Client +# Retain module level imports for structured imports in tests etc. +from . import lib +from . import client + +# Open up the client and function as an easy import. +from .client import Client, create_client + + +__version__ = "0.0.1" diff --git a/supabase_py/client.py b/supabase_py/client.py index 6dba2da8..700a2554 100644 --- a/supabase_py/client.py +++ b/supabase_py/client.py @@ -1,118 +1,121 @@ -import gotrue - from postgrest_py import PostgrestClient -from .lib.supabase_auth_client import SupabaseAuthClient -from .lib.supabase_realtime_client import SupabaseRealtimeClient -from .lib.supabase_query_builder import SupabaseQueryBuilder -from typing import Optional +from supabase_py.lib.auth_client import SupabaseAuthClient +from supabase_py.lib.realtime_client import SupabaseRealtimeClient +from supabase_py.lib.query_builder import SupabaseQueryBuilder + +from typing import Any, Dict DEFAULT_OPTIONS = { "schema": "public", "auto_refresh_token": True, "persist_session": True, - "detect_session_in_url": True, - "headers": {}, + "detect_session_url": True, + "local_storage": {}, } class Client: + """Supabase client class.""" + def __init__( - self, supabaseUrl: str, supabaseKey: str, options: Optional[dict] = {} + self, supabase_url: str, supabase_key: str, **options, ): - """ - Initialize a Supabase Client + """Instantiate the client. + Parameters ---------- - SupabaseUrl - URL of the Supabase instance that we are acting on - SupabaseKey - API key for the Supabase instance that we are acting on - Options - Any other settings that we wish to override + supabase_url: str + The URL to the Supabase instance that should be connected to. + supabase_key: str + The API key to the Supabase instance that should be connected to. + **options + Any extra settings to be optionally specified - also see the + `DEFAULT_OPTIONS` dict. + """ + if not supabase_url: + raise Exception("supabase_url is required") + if not supabase_key: + raise Exception("supabase_key is required") + self.supabase_url = supabase_url + self.supabase_key = supabase_key + # Start with defaults, write headers and prioritise user overwrites. + settings: Dict[str, Any] = { + **DEFAULT_OPTIONS, + "headers": self._get_auth_headers(), + **options, + } + 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.schema: str = settings.pop("schema") + # Instantiate clients. + self.auth: SupabaseAuthClient = self._init_supabase_auth_client( + auth_url=self.auth_url, supabase_key=self.supabase_key, **settings, + ) + # TODO(fedden): Bring up to parity with JS client. + # self.realtime: SupabaseRealtimeClient = self._init_realtime_client( + # realtime_url=self.realtime_url, supabase_key=self.supabase_key, + # ) + self.realtime = None + self.postgrest: PostgrestClient = self._init_postgrest_client( + rest_url=self.rest_url + ) - Returns - None - ------- - """ - if not supabaseUrl: - raise Exception("supabaseUrl is required") - if not supabaseKey: - raise Exception("supabaseKey is required") - - settings = {**DEFAULT_OPTIONS, **options} - self.restUrl = f"{supabaseUrl}/rest/v1" - self.realtimeUrl = f"{supabaseUrl}/realtime/v1".replace("http", "ws") - self.authUrl = f"{supabaseUrl}/auth/v1" - self.schema = settings["schema"] - self.supabaseUrl = supabaseUrl - self.supabaseKey = supabaseKey - self.auth = self._initSupabaseAuthClient(*settings) - # TODO: Fix this once Realtime-py is working - # self.realtime = self._initRealtimeClient() - - def _from(self, table: str): + def table(self, table_name: str) -> SupabaseQueryBuilder: + """Perform a table operation. + + Note that the supabase client uses the `from` method, but in Python, + this is a reserved keyword so we have elected to use the name `table`. + Alternatively you can use the `._from()` method. """ - Perform a table operation on a given table - Parameters - ---------- - table - Name of table to execute operations on - Returns - ------- - SupabaseQueryBuilder - Wrapper for Postgrest-py client which we can perform operations(e.g. select/update) with + return self._from(table_name) + + def _from(self, table_name: str) -> SupabaseQueryBuilder: + """Perform a table operation. + + See the `table` method. """ - url = f"{self.restUrl}/{table}" return SupabaseQueryBuilder( - url, - { - "headers": self._getAuthHeaders(), - "schema": self.schema, - "realtime": self.realtime, - }, - self.schema, - self.realtime, - table, + url=f"{self.rest_url}/{table_name}", + headers=self._get_auth_headers(), + schema=self.schema, + realtime=self.realtime, + table=table_name, ) def rpc(self, fn, params): - """ - Performs a stored procedure call. + """Performs a stored procedure call. Parameters ---------- - fn - The stored procedure call to be execured - params - Parameters passed into the stored procedure call + fn : callable + The stored procedure call to be executed. + params : dict of any + Parameters passed into the stored procedure call. Returns ------- Response - Returns the HTTP Response object which results from executing the call. + Returns the HTTP Response object which results from executing the + call. """ - rest = self._initPostgrestClient() - return rest.rpc(fn, params) + return self.postgrest.rpc(fn, params) - # TODO: Fix this segment after realtime-py is working - # def removeSubscription(self, subscription): # async def remove_subscription_helper(resolve): # try: - # await self._closeSubscription(subscription) - # openSubscriptions = len(self.getSubscriptions()) - # if not openSubscriptions: + # await self._close_subscription(subscription) + # open_subscriptions = len(self.get_subscriptions()) + # if not open_subscriptions: # error = await self.realtime.disconnect() # if error: - # return {"error": None, "data": { openSubscriptions}} - # except Error as e: - # return {error} - + # return {"error": None, "data": { open_subscriptions}} + # except Exception as e: + # raise e # return remove_subscription_helper(subscription) - async def _closeSubscription(self, subscription): - """ - Close a given subscription + async def _close_subscription(self, subscription): + """Close a given subscription Parameters ---------- @@ -122,62 +125,81 @@ async def _closeSubscription(self, subscription): if not subscription.closed: await self._closeChannel(subscription) - def getSubscriptions(self): - """ - Return all channels the the client is subscribed to. - """ + def get_subscriptions(self): + """Return all channels the the client is subscribed to.""" return self.realtime.channels - def _initRealtimeClient(self): - """ - Private method for creating an instance of the realtime-py client. - """ - return RealtimeClient(self.realtimeUrl, {"params": {apikey: self.supabaseKey}}) - - def _initSupabaseAuthClient( - self, - schema, - autoRefreshToken, - persistSession, - detectSessionInUrl, - localStorage, - ): + @staticmethod + def _init_realtime_client( + realtime_url: str, supabase_key: str + ) -> SupabaseRealtimeClient: + """Private method for creating an instance of the realtime-py client.""" + return SupabaseRealtimeClient( + realtime_url, {"params": {"apikey": supabase_key}} + ) + + @staticmethod + def _init_supabase_auth_client( + auth_url: str, + supabase_key: str, + detect_session_url: bool, + auto_refresh_token: bool, + persist_session: bool, + local_storage: Dict[str, Any], + headers: Dict[str, str], + ) -> SupabaseAuthClient: """ Private helper method for creating a wrapped instance of the GoTrue Client. """ return SupabaseAuthClient( - self.authUrl, - autoRefreshToken, - persistSession, - detectSessionInUrl, - localStorage, - headers={ - "Authorization": f"Bearer {self.supabaseKey}", - "apikey": f"{self.supabaseKey}", - }, + auth_url=auth_url, + auto_refresh_token=auto_refresh_token, + detect_session_url=detect_session_url, + persist_session=persist_session, + local_storage=local_storage, + headers=headers, ) - def _initPostgrestClient(self): - """ - Private helper method for creating a wrapped instance of the Postgrest client. - """ - return PostgrestClient(self.restUrl) - - def _getAuthHeaders(self): - """ - Helper method to get auth headers - """ - headers = {} - # TODO: Add way of getting auth token - headers["apiKey"] = self.supabaseKey - headers["Authorization"] = f"Bearer {self.supabaseKey}" + @staticmethod + def _init_postgrest_client(rest_url: str) -> PostgrestClient: + """Private helper for creating an instance of the Postgrest client.""" + return PostgrestClient(rest_url) + + def _get_auth_headers(self) -> Dict[str, str]: + """Helper method to get auth headers.""" + # What's the corresponding method to get the token + headers: Dict[str, str] = { + "apiKey": self.supabase_key, + "Authorization": f"Bearer {self.supabase_key}", + } return headers - # TODO: Fix this segment after realtime-py is working - # def closeSubscription(self): - # if not subscription.closed: - # await self._closeChannel(subscription) - # def _closeChannel(self, subscription): - # async def _closeChannelHelper(): - # subscription.unsubscribe().on('OK') +def create_client(supabase_url: str, supabase_key: str, **options) -> Client: + """Create client function to instanciate supabase client like JS runtime. + + Parameters + ---------- + supabase_url: str + The URL to the Supabase instance that should be connected to. + supabase_key: str + The API key to the Supabase instance that should be connected to. + **options + Any extra settings to be optionally specified - also see the + `DEFAULT_OPTIONS` dict. + + Examples + -------- + Instanciating the client. + >>> import os + >>> from supabase_py import create_client, Client + >>> + >>> url: str = os.environ.get("SUPABASE_TEST_URL") + >>> key: str = os.environ.get("SUPABASE_TEST_KEY") + >>> supabase: Client = create_client(url, key) + + Returns + ------- + Client + """ + return Client(supabase_url=supabase_url, supabase_key=supabase_key, **options) diff --git a/supabase_py/lib/__init__.py b/supabase_py/lib/__init__.py index e69de29b..286d8833 100644 --- a/supabase_py/lib/__init__.py +++ b/supabase_py/lib/__init__.py @@ -0,0 +1,3 @@ +from . import auth_client +from . import query_builder +from . import realtime_client diff --git a/supabase_py/lib/auth_client.py b/supabase_py/lib/auth_client.py new file mode 100644 index 00000000..96fb870a --- /dev/null +++ b/supabase_py/lib/auth_client.py @@ -0,0 +1,45 @@ +from typing import Any, Dict, Optional + +import gotrue + + +class SupabaseAuthClient(gotrue.Client): + """SupabaseAuthClient""" + + def __init__( + self, + auth_url: str, + detect_session_url: bool = False, + auto_refresh_token: bool = False, + persist_session: bool = False, + local_storage: Optional[Dict[str, Any]] = None, + headers: Dict[str, str] = {}, + ): + """Instanciate SupabaseAuthClient instance.""" + super().__init__(auth_url) + self.headers = headers + self.detect_session_url = detect_session_url + self.auto_refresh_token = auto_refresh_token + self.persist_session = persist_session + self.local_storage = local_storage + self.jwt = None + + def sign_in(self, email: str, password: str) -> Dict[str, Any]: + """Sign in with email and password.""" + response = super().sign_in(credentials={"email": email, "password": password}) + # TODO(fedden): Log JWT to self.jwt + return response.json() + + def sign_up(self, email: str, password: str) -> Dict[str, Any]: + """Sign up with email and password.""" + response = super().sign_up(credentials={"email": email, "password": password}) + # TODO(fedden): Log JWT to self.jwt + return response.json() + + def sign_out(self) -> Dict[str, Any]: + """Sign out of logged in user.""" + if self.jwt is None: + raise ValueError("Cannot sign out if not signed in.") + response = super().sign_out(jwt=self.jwt) + self.jwt = None + return response.json() diff --git a/supabase_py/lib/supabase_query_builder.py b/supabase_py/lib/query_builder.py similarity index 94% rename from supabase_py/lib/supabase_query_builder.py rename to supabase_py/lib/query_builder.py index 58d03c05..9c2f223a 100644 --- a/supabase_py/lib/supabase_query_builder.py +++ b/supabase_py/lib/query_builder.py @@ -1,6 +1,5 @@ from postgrest_py.client import PostgrestClient -from .supabase_realtime_client import SupabaseRealtimeClient -from typing import Callable +from .realtime_client import SupabaseRealtimeClient class SupabaseQueryBuilder(PostgrestClient): diff --git a/supabase_py/lib/supabase_realtime_client.py b/supabase_py/lib/realtime_client.py similarity index 82% rename from supabase_py/lib/supabase_realtime_client.py rename to supabase_py/lib/realtime_client.py index 7b88e644..8f5eae11 100644 --- a/supabase_py/lib/supabase_realtime_client.py +++ b/supabase_py/lib/realtime_client.py @@ -1,16 +1,18 @@ -from typing import Callable, Any +from typing import Any, Callable + +from realtime_py.connection import Socket class SupabaseRealtimeClient: - def __init__(self, socket, schema, tableName): + def __init__(self, socket, schema, table_name): topic = ( f"realtime:{schema}" if table_name == "*" - else f"realtime:{schema}:{tableName}" + else f"realtime:{schema}:{table_name}" ) self.subscription = socket.set_channel(topic) - def getPayloadRecords(self, payload: Any): + def get_payload_records(self, payload: Any): records = {"new": {}, "old": {}} # TODO: Figure out how to create payload # if payload.type == "INSERT" or payload.type == "UPDATE": @@ -29,7 +31,7 @@ def cb(payload): "new": {}, "old": {}, } - enriched_payload = {**enriched_payload, **self.getPayloadRecords(payload)} + enriched_payload = {**enriched_payload, **self.get_payload_records(payload)} callback(enriched_payload) self.subscription.join().on(event, cb) diff --git a/supabase_py/lib/supabase_auth_client.py b/supabase_py/lib/supabase_auth_client.py deleted file mode 100644 index 588cc4ed..00000000 --- a/supabase_py/lib/supabase_auth_client.py +++ /dev/null @@ -1,19 +0,0 @@ -import gotrue - - -class SupabaseAuthClient(gotrue.Client): - def __init__( - self, - authURL, - detectSessionInUrl=False, - autoRefreshToken=False, - persistSession=False, - localStorage=None, - headers=None, - ): - super().__init__(authURL) - self.headers = headers - self.detectSessionInUrl = detectSessionInUrl - self.autoRefreshToken = autoRefreshToken - self.persistSession = persistSession - self.localStorage = localStorage diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 00000000..22b54c59 --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,65 @@ +import os +import random +import string +from typing import Any, Dict + +import pytest + + +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(user: Dict[str, Any]): + """Raise assertion error if user is not logged in correctly.""" + assert user.get("id") is not None + assert user.get("aud") == "authenticated" + + +def _assert_unauthenticated_user(user: Dict[str, Any]): + """Raise assertion error if user is logged in correctly.""" + assert False + + +@pytest.mark.xfail( + reason="None of these values should be able to instanciate a client object" +) +@pytest.mark.parametrize("url", ["", None, "valeefgpoqwjgpj", 139, -1, {}, []]) +@pytest.mark.parametrize("key", ["", None, "valeefgpoqwjgpj", 139, -1, {}, []]) +def test_incorrect_values_dont_instanciate_client(url: Any, key: Any): + """Ensure we can't instanciate client with nonesense values.""" + from supabase_py import create_client, Client + + _: Client = create_client(url, key) + + +def test_client_auth(): + """Ensure we can create an auth user, and login with it.""" + from supabase_py import create_client, Client + + url: str = os.environ.get("SUPABASE_TEST_URL") + key: str = os.environ.get("SUPABASE_TEST_KEY") + supabase: Client = create_client(url, key) + # Create a random user login email and password. + random_email: str = f"{_random_string(10)}@supamail.com" + random_password: str = _random_string(20) + # Sign up (and sign in). + user = supabase.auth.sign_up(email=random_email, password=random_password) + _assert_authenticated_user(user) + # Sign out. + user = supabase.auth.sign_out() + _assert_unauthenticated_user(user) + # Sign in (explicitly this time). + user = supabase.auth.sign_in(email=random_email, password=random_password) + _assert_authenticated_user(user) + + +def test_client_select(): + """Ensure we can select data from a table.""" + from supabase_py import create_client, Client + + url: str = os.environ.get("SUPABASE_TEST_URL") + key: str = os.environ.get("SUPABASE_TEST_KEY") + supabase: Client = create_client(url, key) + data = supabase.table("countries").select("*")