diff --git a/README.md b/README.md index e51a649..d3ec9bd 100644 --- a/README.md +++ b/README.md @@ -3,13 +3,12 @@ A Streamlit connection component to connect Streamlit to Supabase Storage and Database. ## :thinking: Why use this? -- [X] A new `query()` method available to run cached select queries on the database. **Save time and money** on your API requests -- [X] **Same method names as the Supabase Python API** -- [X] It is built on top of [`storage-py`](https://github.com/supabase-community/storage-py) and **exposes more methods** than currently supported by the Supabase Python API. For example, `update()`, `create_signed_upload_url()`, and `upload_to_signed_url()` -- [X] **Consistent logging syntax.** All statements follow the syntax `client.method("bucket_id", **options)` +- [X] Cache functionality to cache returned results. **Save time and money** on your API requests +- [X] Same method names as the Supabase Python API. **Minimum relearning required** +- [X] Built on top of [`storage-py`](https://github.com/supabase-community/storage-py) and **exposes more methods** than currently supported by the Supabase Python API. For example, `update()`, `create_signed_upload_url()`, and `upload_to_signed_url()` - [X] **Less keystrokes required** when integrating with your Streamlit app. -
+
Examples with and without the connector
@@ -133,7 +132,7 @@ pip install st-supabase-connection ``` 2. Set the `SUPABASE_URL` and `SUPABASE_KEY` Streamlit secrets as described [here](https://docs.streamlit.io/streamlit-community-cloud/get-started/deploy-an-app/connect-to-data-sources/secrets-management). -> [!NOTE] +> [!INFO] > For local development outside Streamlit, you can also set these as your environment variables (recommended), or pass these to the `url` and `key` args of `st.experimental_connection()` (not recommended). ## :pen: Usage @@ -147,18 +146,19 @@ pip install st-supabase-connection st_supabase_client = st.experimental_connection( name="YOUR_CONNECTION_NAME", type=SupabaseConnection, + ttl=None, url="YOUR_SUPABASE_URL", # not needed if provided as a streamlit secret key="YOUR_SUPABASE_KEY", # not needed if provided as a streamlit secret ) ``` -3. Happy Streamlit-ing! :balloon: +3. Use in your app to query tables and files. Happy Streamlit-ing! :balloon: ## :writing_hand: Examples ### :package: Storage operations #### List existing buckets ```python ->>> st_supabase.list_buckets() +>>> st_supabase.list_buckets(ttl=None) [ SyncBucket( id="bucket1", @@ -206,7 +206,7 @@ pip install st-supabase-connection #### List objects in a bucket ```python ->>> st_supabase_client.list_objects("new_bucket", path="folder1") +>>> st_supabase_client.list_objects("new_bucket", path="folder1", ttl=0) [ { "name": "new_test.png", @@ -234,7 +234,7 @@ pip install st-supabase-connection ### :file_cabinet: Database operations #### Simple query ```python ->>> st_supabase.query("*", from_="countries", ttl=None).execute() +>>> st_supabase.query("*", table="countries", ttl=0).execute() APIResponse( data=[ {"id": 1, "name": "Afghanistan"}, @@ -246,7 +246,7 @@ APIResponse( ``` #### Query with join ```python ->>> st_supabase.query("name, teams(name)", from_="users", count="exact", ttl=None).execute() +>>> st_supabase.query("name, teams(name)", table="users", count="exact", ttl="1h").execute() APIResponse( data=[ {"name": "Kiran", "teams": [{"name": "Green"}, {"name": "Blue"}]}, @@ -257,7 +257,7 @@ APIResponse( ``` #### Filter through foreign tables ```python ->>> st_supabase.query("name, countries(*)", count="exact", from_="cities", ttl=0).eq( +>>> st_supabase.query("name, countries(*)", count="exact", table="cities", ttl=None).eq( "countries.name", "Curaçao" ).execute() @@ -310,7 +310,7 @@ APIResponse( > [!INFO] > Check the [Supabase Python API reference](https://supabase.com/docs/reference/python/select) for more examples. -## :star: Explore all options in Streamlit +## :star: Explore all options in a Streamlit app [![Open in Streamlit](https://static.streamlit.io/badges/streamlit_badge_black_white.svg)](https://st-supabase-connection.streamlit.app/) ## :bow: Acknowledgements diff --git a/demo/app.py b/demo/app.py index 6300e6c..2f33380 100644 --- a/demo/app.py +++ b/demo/app.py @@ -1,5 +1,6 @@ import contextlib +import pandas as pd import streamlit as st from st_supabase_connection import SupabaseConnection, __version__ @@ -7,7 +8,7 @@ VERSION = __version__ st.set_page_config( - page_title="Streamlit SupabaseConnection Demo app", + page_title="st_supabase_connection", page_icon="🔌", menu_items={ "About": f"🔌 Streamlit Supabase Connection v{VERSION} " @@ -89,10 +90,20 @@ 5. The app will construct the statement for that you can copy and use in your own app. """ ) + + if st.button( + "Clear the cache to fetch latest data🧹", + use_container_width=True, + type="primary", + ): + st.cache_data.clear() + st.cache_resource.clear() + st.success("Cache cleared") + st.components.v1.html(sidebar_html, height=600) # ---------- MAIN PAGE ---------- -st.header("🔌Streamlit SupabaseConnection Demo") +st.header("🔌Supabase Connection for Streamlit") st.write("📖 Demo and tutorial for `st_supabase_connection` for Supabase Storage and Database.") @@ -102,10 +113,14 @@ demo_tab, custom_tab = st.tabs(["👶Use demo project", "🫅Use your own project"]) with demo_tab: - st.info( - "Limited data and operations", - icon="⚠️", + ttl = st.text_input( + "Connection cache duration", + value="", + placeholder="Optional", + help="This does not affect results caching. Leave blank to cache indefinitely.", ) + ttl = None if ttl == "" else ttl + if st.button( "Initialize client ⚡", type="primary", @@ -113,8 +128,7 @@ ): try: st.session_state["client"] = st.experimental_connection( - name="supabase_connection", - type=SupabaseConnection, + name="supabase_connection", type=SupabaseConnection, ttl=ttl ) st.session_state["initialized"] = True st.session_state["project"] = "demo" @@ -128,9 +142,9 @@ st.write("A connection is initialized as") st.code( - """ + f""" st_supabase = st.experimental_connection( - name="supabase_connection", type=SupabaseConnection + name="supabase_connection", type=SupabaseConnection, {ttl=} ) """, language="python", @@ -138,7 +152,15 @@ with custom_tab: with st.form(key="credentials"): - url = st.text_input("Enter Supabase URL") + lcol, rcol = st.columns([2, 1]) + url = lcol.text_input("Enter Supabase URL") + ttl = rcol.text_input( + "Connection cache duration", + value="", + placeholder="Optional", + help="This does not affect results caching. Leave blank to cache indefinitely", + ) + ttl = None if ttl == "" else ttl key = st.text_input("Enter Supabase key", type="password") if st.form_submit_button( @@ -150,6 +172,7 @@ st.session_state["client"] = st.experimental_connection( name="supabase_connection", type=SupabaseConnection, + ttl=ttl, url=url, key=key, ) @@ -165,10 +188,14 @@ st.write("A connection is initialized as") st.code( - """ + f""" st_supabase = st.experimental_connection( - name="supabase_connection", type=SupabaseConnection, url=url, key=key - ) + name="supabase_connection", + type=SupabaseConnection, + {ttl=}, + url=url, + key=key, + ) """, language="python", ) @@ -217,12 +244,23 @@ bucket_id = rcol.text_input( "Enter the bucket id", - placeholder="my_bucket" if operation != "update_bucket" else "", + placeholder="Required" if operation != "list_buckets" else "", disabled=operation == "list_buckets", help="The unique identifier for the bucket", ) - if operation in ["delete_bucket", "empty_bucket", "get_bucket"]: + if operation == "get_bucket": + ttl = st.text_input( + "Results cache duration", + value="", + placeholder="Optional", + help="This does not affect results caching. Leave blank to cache indefinitely", + ) + ttl = None if ttl == "" else ttl + constructed_storage_query = f"""st_supabase.{operation}("{bucket_id}", {ttl=})""" + st.session_state["storage_disabled"] = False if bucket_id else True + + elif operation in ["delete_bucket", "empty_bucket"]: constructed_storage_query = f"""st_supabase.{operation}("{bucket_id}")""" st.session_state["storage_disabled"] = False if bucket_id else True @@ -314,17 +352,32 @@ """ elif operation == "list_buckets": - constructed_storage_query = f"""st_supabase.{operation}()""" + ttl = st.text_input( + "Results cache duration", + value="", + placeholder="Optional", + help="This does not affect results caching. Leave blank to cache indefinitely", + ) + ttl = None if ttl == "" else ttl + constructed_storage_query = f"""st_supabase.{operation}({ttl=})""" st.session_state["storage_disabled"] = False elif operation == "download": - source_path = st.text_input( + lcol, rcol = st.columns([3, 1]) + source_path = lcol.text_input( "Enter source path in the bucket", placeholder="/folder/subFolder/file.txt", ) + ttl = rcol.text_input( + "Results cache duration", + value="", + placeholder="Optional", + help="This does not affect results caching. Leave blank to cache indefinitely", + ) + ttl = None if ttl == "" else ttl constructed_storage_query = ( - f"""st_supabase.{operation}("{bucket_id}", {source_path=})""" + f"""st_supabase.{operation}("{bucket_id}", {source_path=}, {ttl=})""" ) st.session_state["storage_disabled"] = False if all([bucket_id, source_path]) else True @@ -355,10 +408,18 @@ st.session_state["storage_disabled"] = False if all([bucket_id, paths]) else True elif operation == "list_objects": - path = st.text_input( + lcol, rcol = st.columns([3, 1]) + path = lcol.text_input( "Enter the folder path to list objects from", placeholder="/folder/subFolder/", ) + ttl = rcol.text_input( + "Results cache duration", + value="", + placeholder="Optional", + help="This does not affect results caching. Leave blank to cache indefinitely", + ) + ttl = None if ttl == "" else ttl col1, col2, col3, col4 = st.columns(4) @@ -387,17 +448,27 @@ horizontal=True, ) - constructed_storage_query = f"""st_supabase.{operation}("{bucket_id}", {path=}, {limit=}, {offset=}, {sortby=}, {order=})""" + constructed_storage_query = f"""st_supabase.{operation}("{bucket_id}", {path=}, {limit=}, {offset=}, {sortby=}, {order=}, {ttl=})""" st.session_state["storage_disabled"] = False if bucket_id else True elif operation == "get_public_url": - filepath = st.text_input( + lcol, rcol = st.columns([3, 1]) + filepath = lcol.text_input( "Enter the path to file", placeholder="/folder/subFolder/image.jpg", ) + ttl = rcol.text_input( + "Results cache duration", + value="", + placeholder="Optional", + help="This does not affect results caching. Leave blank to cache indefinitely", + ) + ttl = None if ttl == "" else ttl - constructed_storage_query = f"""st_supabase.get_public_url("{bucket_id}",{filepath=})""" + constructed_storage_query = ( + f"""st_supabase.get_public_url("{bucket_id}",{filepath=}, {ttl=})""" + ) st.session_state["storage_disabled"] = False if all([bucket_id, filepath]) else True elif operation == "create_signed_urls": @@ -538,7 +609,8 @@ st.write(response) elif operation == "list_objects": st.info(f"Listing **{len(response)}** objects") - st.write(response) + _df = pd.DataFrame.from_dict(response) + st.dataframe(_df, use_container_width=True) elif operation == "get_public_url": st.success(response, icon="🔗") elif operation == "create_signed_urls": @@ -659,12 +731,12 @@ request_builder_query_label = "Enter the columns to fetch as comma-separated strings" placeholder = value = "*" ttl = rcol_placeholder.text_input( - "Enter cache expiry duration", + "Result cache duration", value=0, placeholder=None, help="Set as `0` to always fetch the latest results, and leave blank to cache indefinitely.", ) - ttl = None if ttl == "" else ttl + placeholder = value = "*" elif request_builder == "delete": request_builder_query_label = "Delete query" placeholder = value = "Delete does not take a request builder query" @@ -715,14 +787,18 @@ operators = operators.replace(".__init__()", "").replace(".execute()", "") + ttl = None if ttl == "" else ttl + if operators: if request_builder == "select": - constructed_db_query = f"""st_supabase.query({request_builder_query}, from_="{table}", {ttl=}){operators}.execute()""" + constructed_db_query = f"""st_supabase.query({request_builder_query}, {table=}, {ttl=}){operators}.execute()""" else: constructed_db_query = f"""st_supabase.table("{table}").{request_builder}({request_builder_query}){operators}.execute()""" else: if request_builder == "select": - constructed_db_query = f"""st_supabase.query({request_builder_query}, from_="{table}", {ttl=}).execute()""" + constructed_db_query = ( + f"""st_supabase.query({request_builder_query}, {table=}, {ttl=}).execute()""" + ) else: constructed_db_query = f"""st_supabase.table("{table}").{request_builder}({request_builder_query}).execute()""" st.write("**Constructed statement**") @@ -751,7 +827,9 @@ data, count = eval(constructed_db_query) if count_method: - st.write(f"{count[-1]} rows {request_builder}ed") + st.write( + f"**{count[-1]}** rows {request_builder}ed. `count` does not take `limit` into account." + ) if view == "Dataframe": st.dataframe(data[-1], use_container_width=True) else: diff --git a/src/st_supabase_connection/__init__.py b/src/st_supabase_connection/__init__.py index a02eb95..620f1f5 100644 --- a/src/st_supabase_connection/__init__.py +++ b/src/st_supabase_connection/__init__.py @@ -5,22 +5,11 @@ from typing import Literal, Optional, Tuple, Union, types from postgrest import SyncSelectRequestBuilder, types -from streamlit import cache_resource +from streamlit import cache_data, cache_resource from streamlit.connections import ExperimentalBaseConnection from supabase import Client, create_client -# TODO: Use cache_data wherever possible (pass ttl = 0 to disable caching) -# eg -# def get_bucket(ttl): -# @cache_data(ttl=ttl) -# def _get_bucket(): -# return self.client.storage.get_bucket -# return _get_bucket -# TODO: Add cache to benefits in readme if implemented -# TODO: Add optional headers to storage requests -# TODO: support additional postgrest-py methods (https://github.com/supabase-community/postgrest-py/blob/master/postgrest/_sync/request_builder.py#L177C13-L177C13) - -__version__ = "0.0.4" +__version__ = "0.1.0" class SupabaseConnection(ExperimentalBaseConnection[Client]): @@ -71,26 +60,23 @@ def _connect(self, **kwargs) -> None: self.client = create_client(url, key) self.table = self.client.table - self.get_bucket = self.client.storage.get_bucket - self.list_buckets = self.client.storage.list_buckets self.delete_bucket = self.client.storage.delete_bucket self.empty_bucket = self.client.storage.empty_bucket def query( self, *columns: str, - from_: str, + table: str, count: Optional[types.CountMethod] = None, - ttl: Optional[Union[int, str, timedelta]] = None, + ttl: Optional[Union[float, timedelta, str]] = None, ) -> SyncSelectRequestBuilder: - """ - Run a SELECT query. + """Run a SELECT query. Parameters ---------- *columns : str The names of the columns to fetch. - from_ : str + table : str The table to run the query on. count : str The method to use to get the count of rows returned. Defaults to `None`. @@ -98,13 +84,50 @@ def query( The maximum time to keep an entry in the cache. Defaults to `None` (cache never expires). """ - @cache_resource( - ttl=ttl - ) # The return object is not serializable. This behaviour was retained to let users chain operations to the query - def _query(_self, *columns, from_, count): - return _self.client.table(from_).select(*columns, count=count) + @cache_resource(ttl=ttl) + def _query(_self, *columns, table, count): + return _self.client.table(table).select(*columns, count=count) + + return _query(self, *columns, table=table, count=count) + + def get_bucket( + self, + bucket_id: str, + ttl: Optional[Union[float, timedelta, str]] = None, + ): + """Retrieves the details of an existing storage bucket. + + Parameters + ---------- + bucket_id : str + Unique identifier of the bucket you would like to retrieve. + ttl : float, timedelta, str, or None + The maximum time to keep an entry in the cache. Defaults to `None` (cache never expires). + """ + + @cache_resource(ttl=ttl) + def _get_bucket(_self, bucket_id): + return _self.client.storage.get_bucket(bucket_id) + + return _get_bucket(self, bucket_id) - return _query(self, *columns, from_=from_, count=count) + def list_buckets( + self, + ttl: Optional[Union[float, timedelta, str]] = None, + ) -> list: + """Retrieves the details of all storage buckets within an existing product. + + Parameters + ---------- + ttl : float, timedelta, str, or None + The maximum time to keep an entry in the cache. Defaults to `None` (cache never expires). + """ + + @cache_resource(ttl=ttl) + def _list_buckets(_self): + return _self.client.storage.list_buckets() + + return _list_buckets(self) def create_bucket( self, @@ -114,8 +137,7 @@ def create_bucket( file_size_limit: Optional[int] = None, allowed_mime_types: Optional[list[str]] = None, ) -> dict[str, str]: - """ - Creates a new storage bucket. + """Creates a new storage bucket. Parameters ---------- @@ -144,8 +166,7 @@ def create_bucket( return response.json() def upload(self, bucket_id: str, file: BytesIO, destination_path: str) -> dict[str, str]: - """ - Uploads a file to a Supabase bucket. + """Uploads a file to a Supabase bucket. Parameters ---------- @@ -170,9 +191,9 @@ def download( self, bucket_id: str, source_path: str, + ttl: Optional[Union[float, timedelta, str]] = None, ) -> Tuple[str, str, bytes]: - """ - Downloads a file. + """Downloads a file. Parameters ---------- @@ -180,6 +201,8 @@ def download( Unique identifier of the bucket. source_path : str Path of the file relative in the bucket, including file name + ttl : float, timedelta, str, or None + The maximum time to keep an entry in the cache. Defaults to `None` (cache never expires). Returns ------- @@ -192,16 +215,20 @@ def download( """ import mimetypes - file_name = source_path.split("/")[-1] + @cache_resource(ttl=ttl) + def _download(_self, bucket_id, source_path): + file_name = source_path.split("/")[-1] + + with open(file_name, "wb+") as f: + response = _self.client.storage.from_(bucket_id).download(source_path) + f.write(response) - with open(file_name, "wb+") as f: - response = self.client.storage.from_(bucket_id).download(source_path) - f.write(response) + mime = mimetypes.guess_type(file_name)[0] + data = open(file_name, "rb") - mime = mimetypes.guess_type(file_name)[0] - data = open(file_name, "rb") + return file_name, mime, data - return file_name, mime, data + return _download(self, bucket_id, source_path) def update_bucket( self, @@ -210,8 +237,7 @@ def update_bucket( file_size_limit: Optional[bool] = None, allowed_mime_types: Optional[Union[str, list[str]]] = None, ) -> dict[str, str]: - """ - Update a storage bucket. + """Update a storage bucket. Parameters ---------- @@ -235,8 +261,7 @@ def update_bucket( return response.json() def move(self, bucket_id: str, from_path: str, to_path: str) -> dict[str, str]: - """ - Moves an existing file, optionally renaming it at the same time. + """Moves an existing file, optionally renaming it at the same time. Parameters ---------- @@ -259,8 +284,7 @@ def move(self, bucket_id: str, from_path: str, to_path: str) -> dict[str, str]: return response.json() def remove(self, bucket_id: str, paths: list) -> dict[str, str]: - """ - Deletes files within the same bucket + """Deletes files within the same bucket Parameters ---------- @@ -284,9 +308,9 @@ def list_objects( offset: int = 0, sortby: Optional[Literal["name", "updated_at", "created_at", "last_accessed_at"]] = "name", order: Optional[Literal["asc", "desc"]] = "asc", + ttl: Optional[Union[float, timedelta, str]] = None, ) -> list[dict[str, str]]: - """ - Lists all the objects within a bucket. + """Lists all the objects within a bucket. Parameters ---------- @@ -302,15 +326,22 @@ def list_objects( The column name to sort by by. Defaults to "name". order : str The sorting order. Defaults to "asc". + ttl : float, timedelta, str, or None + The maximum time to keep an entry in the cache. Defaults to `None` (cache never expires). """ - return self.client.storage.from_(bucket_id).list( - path, - dict( - limit=limit, - offset=offset, - sortBy=dict(column=sortby, order=order), - ), - ) + + @cache_data(ttl=ttl) + def _list_objects(_self, bucket_id, path, limit, offset, sortby, order): + return _self.client.storage.from_(bucket_id).list( + path, + dict( + limit=limit, + offset=offset, + sortBy=dict(column=sortby, order=order), + ), + ) + + return _list_objects(self, bucket_id, path, limit, offset, sortby, order) def create_signed_urls( self, @@ -318,8 +349,7 @@ def create_signed_urls( paths: list[str], expires_in: int, ) -> list[dict[str, str]]: - """ - Parameters + """Parameters ---------- bucket_id : str Unique identifier of the bucket. @@ -348,23 +378,26 @@ def get_public_url( self, bucket_id: str, filepath: str, + ttl: Optional[Union[float, timedelta, str]] = None, ) -> str: - """ - Parameters + """Parameters ---------- bucket_id : str Unique identifier of the bucket. filepath : str File path to be downloaded, including the current file name. + ttl : float, timedelta, str, or None + The maximum time to keep an entry in the cache. Defaults to `None` (cache never expires). """ - return self.client.storage.from_(bucket_id).get_public_url(filepath) + @cache_data(ttl) + def _get_public_url(_self, bucket_id, filepath): + return _self.client.storage.from_(bucket_id).get_public_url(filepath) - def create_signed_upload_url(self, bucket_id: str, path: str) -> dict[str, str]: - """ - Creates a signed upload URL. + return _get_public_url(self, bucket_id, filepath) - Parameters + def create_signed_upload_url(self, bucket_id: str, path: str) -> dict[str, str]: + """Parameters ---------- bucket_id : str Unique identifier of the bucket. @@ -398,8 +431,7 @@ def upload_to_signed_url( token: str, file: BytesIO, ) -> dict[str, str]: - """ - Upload a file with a token generated from `.create_signed_url()` + """Upload a file with a token generated from `.create_signed_url()` Parameters ----------