diff --git a/.gitignore b/.gitignore
index 715b9467..81d522fe 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,24 @@
+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~
.idea
# Byte-compiled / optimized / DLL files
@@ -16,7 +37,6 @@ dist/
downloads/
eggs/
.eggs/
-lib/
lib64/
parts/
sdist/
diff --git a/LICENSE b/LICENSE
index ddeba6a0..8191ed13 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,6 +1,6 @@
MIT License
-Copyright (c) 2020 Supabase
+Copyright (c) 2020 Joel Lee
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/README.md b/README.md
index c3d85f36..a6c26458 100644
--- a/README.md
+++ b/README.md
@@ -1,13 +1,113 @@
# supabase-py
-Supabase client for Python.
+[](https://gotrue-py.readthedocs.io/en/latest/?badge=latest)
-### See issues for what to work on
+Supabase client for Python. This mirrors the design of [supabase-js](https://github.com/supabase/supabase-js/blob/master/README.md)
-Rough roadmap:
+## Installation
+
+**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"
+```
+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)
+```
+
+### 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/)
-- [ ] Write Realtime-py (Use [realtime-js](https://github.com/supabase/realtime-js) as reference implementation) (implementation started by @Jeffery Kwoh @Joel and @Lionell Loh)
-- [ ] Wrap Realtime-py (Use [supabase-js](https://github.com/supabase/supabase-js) as reference implementation)
-- [ ] Write Gotrue-py (for auth) (Use [gotrue-js](https://github.com/netlify/gotrue-js) as reference implementation)
-- [ ] Wrap Gotrue-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({
+ "email": 'example@email.com',
+ "password": 'example-password',
+})
+```
+
+
+## Sign-in
+```
+supabase.auth.signIn({
+ "email": 'example@email.com',
+ "password": 'example-password',
+})
+```
+
+
+## Sign-in(Auth provider). This is not supported yet
+```
+supabase.auth.signIn({
+ // provider can be 'github', 'google', 'gitlab', or 'bitbucket'
+ "provider": 'github'
+})
+```
+
+
+## Managing Data
+```
+supabase
+ .from('countries')
+ .select("
+ name,
+ cities (
+ name
+ )
+ ")
+```
+
+## Realtime Changes
+```
+mySubscription = supabase
+ .from('countries')
+ .on('*', lambda x: print(x))
+ .subscribe()
+ ```
+See [Supabase Docs](https://supabase.io/docs/guides/client-libraries) for full list of examples
diff --git a/pyproject.toml b/pyproject.toml
index 4dff867a..d73e2983 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -2,15 +2,22 @@
name = "supabase-py"
version = "0.0.1"
description = "Supabase client for Python."
-authors = ["Lương Quang Mạnh "]
+authors = ["Joel Lee "]
license = "MIT"
[tool.poetry.dependencies]
-python = "^3.7"
+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 e69de29b..2ac32a05 100644
--- a/supabase_py/__init__.py
+++ b/supabase_py/__init__.py
@@ -0,0 +1,9 @@
+# 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 3434b5d1..afaecb59 100644
--- a/supabase_py/client.py
+++ b/supabase_py/client.py
@@ -1,33 +1,207 @@
-from typing import Optional
+from postgrest_py import PostgrestClient
+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 SupabaseClient:
- """
- creates a new client for use in the browser
- """
+class Client:
+ """Supabase client class."""
- def __init__(self, supabase_url, supabase_key, options: Optional[dict] = None):
- """
- :param str supabase_url: The unique Supabase URL which is supplied when you create a new project in your
- project dashboard.
- :param str supabase_key: The unique Supabase Key which is supplied when you create a new project in your project
- dashboard.
- :param dict options: a dictionary of various config for Supabase
+ def __init__(
+ self, supabase_url: str, supabase_key: str, **options,
+ ):
+ """Instantiate the client.
+
+ 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.
"""
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
+ )
+
+ 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.
+ """
+ return self._from(table_name)
+
+ def _from(self, table_name: str) -> SupabaseQueryBuilder:
+ """Perform a table operation.
+
+ See the `table` method.
+ """
+ return SupabaseQueryBuilder(
+ 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.
+
+ Parameters
+ ----------
+ 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.
+ """
+ return self.postgrest.rpc(fn, params)
+
+ # async def remove_subscription_helper(resolve):
+ # try:
+ # 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": { open_subscriptions}}
+ # except Exception as e:
+ # raise e
+ # return remove_subscription_helper(subscription)
+
+ async def _close_subscription(self, subscription):
+ """Close a given subscription
+
+ Parameters
+ ----------
+ subscription
+ The name of the channel
+ """
+ if not subscription.closed:
+ await self._closeChannel(subscription)
+
+ def get_subscriptions(self):
+ """Return all channels the the client is subscribed to."""
+ return self.realtime.channels
+
+ @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(
+ 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,
+ )
+
+ @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
+
+
+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)
- settings = {**DEFAULT_OPTIONS, **options}
- self.rest_url = f"{supabase_url}/rest/v1"
- self.schema = settings["schema"]
diff --git a/supabase_py/lib/__init__.py b/supabase_py/lib/__init__.py
new file mode 100644
index 00000000..286d8833
--- /dev/null
+++ 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/query_builder.py b/supabase_py/lib/query_builder.py
new file mode 100644
index 00000000..9c2f223a
--- /dev/null
+++ b/supabase_py/lib/query_builder.py
@@ -0,0 +1,48 @@
+from postgrest_py.client import PostgrestClient
+from .realtime_client import SupabaseRealtimeClient
+
+
+class SupabaseQueryBuilder(PostgrestClient):
+ def __init__(self, url, headers, schema, realtime, table):
+ """
+ Subscribe to realtime changes in your database.
+
+ Parameters
+ ----------
+ url
+ Base URL of the Supabase Instance that the client library is acting on
+ headers
+ authentication/authorization headers which are passed in.
+ schema
+ schema of table that we are building queries for
+ realtime
+ realtime-py client
+ table
+ Name of table to look out for operations on
+ Returns
+ -------
+ None
+ """
+ super().__init__(url)
+ self._subscription = SupabaseRealtimeClient(realtime, schema, table)
+ self._realtime = realtime
+
+ def on(self, event, callback):
+ """
+ Subscribe to realtime changes in your database.
+
+ Parameters
+ ----------
+ event
+ the event which we are looking out for.
+ callback
+ function to be execute when the event is received
+
+ Returns
+ -------
+ SupabaseRealtimeClient
+ Returns an instance of a SupabaseRealtimeClient to allow for chaining.
+ """
+ if not self._realtime.connected:
+ self._realtime.connect()
+ return self._subscription.on(event, callback)
diff --git a/supabase_py/lib/realtime_client.py b/supabase_py/lib/realtime_client.py
new file mode 100644
index 00000000..8f5eae11
--- /dev/null
+++ b/supabase_py/lib/realtime_client.py
@@ -0,0 +1,49 @@
+from typing import Any, Callable
+
+from realtime_py.connection import Socket
+
+
+class SupabaseRealtimeClient:
+ def __init__(self, socket, schema, table_name):
+ topic = (
+ f"realtime:{schema}"
+ if table_name == "*"
+ else f"realtime:{schema}:{table_name}"
+ )
+ self.subscription = socket.set_channel(topic)
+
+ 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":
+ # records.new = Transformers.convertChangeData(payload.columns, payload.record)
+ # if (payload.type === 'UPDATE' || payload.type === 'DELETE'):
+ # records.old = Transformers.convertChangeData(payload.columns, payload.old_record)
+ return records
+
+ def on(self, event, callback: Callable[..., Any]):
+ def cb(payload):
+ enriched_payload = {
+ "schema": payload.schema,
+ "table": payload.table,
+ "commit_timestamp": payload.commit_timestamp,
+ "event_type": payload.type,
+ "new": {},
+ "old": {},
+ }
+ enriched_payload = {**enriched_payload, **self.get_payload_records(payload)}
+ callback(enriched_payload)
+
+ self.subscription.join().on(event, cb)
+ return self
+
+ def subscribe(self, callback: Callable[..., Any]):
+ # TODO: Handle state change callbacks for error and close
+ self.subscription.join().on("ok", callback("SUBSCRIBED"))
+ self.subscription.join().on(
+ "error", lambda x: callback("SUBSCRIPTION_ERROR", x)
+ )
+ self.subscription.join().on(
+ "timeout", lambda: callback("RETRYING_AFTER_TIMEOUT")
+ )
+ return self.subscription
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("*")
diff --git a/tests/test_dummy.py b/tests/test_dummy.py
index 5e20bfd9..c9fa8ef5 100644
--- a/tests/test_dummy.py
+++ b/tests/test_dummy.py
@@ -1,2 +1,20 @@
+import pytest
+
+import sys
+print(sys.path)
+
+import supabase_py
+
+"""
+Convert this flow into a test
+client = supabase_py.Client("", "")
+client.auth.sign_up({"email": "anemail@gmail.com", "password": "apassword"})
+"""
+
def test_dummy():
+ # Test auth component
assert True == True
+
+def test_client_initialziation():
+ client = supabase_py.Client("http://testwebsite.com", "atestapi")
+