From ff275f37f5937a6eb838e63beece7ad3549ef460 Mon Sep 17 00:00:00 2001 From: Nathan Jones Date: Wed, 8 Mar 2023 11:18:06 -0800 Subject: [PATCH 1/3] Firebase naming specific to my use case --- .gitignore | 3 ++ README.md | 40 ++++++++++++++ streamlit_analytics/firestore.py | 38 ++++++++++---- streamlit_analytics/main.py | 90 ++++++++++++++++++++++++-------- test.py | 7 +++ 5 files changed, 146 insertions(+), 32 deletions(-) create mode 100644 test.py diff --git a/.gitignore b/.gitignore index 182af86..e2935e7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# Streamlit +.streamlit/secrets.toml + firestore-key.json results.json .vscode diff --git a/README.md b/README.md index 2685f08..ac80c83 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,46 @@ your app (see image above). # or pass the same args to `start_tracking` AND `stop_tracking` ``` +- If you don't want to push your `firebase-key.json` to GitHub, you can do the following to securely deploy on Streamlit Cloud, or your own hosting solution. + +1. Run this code to create the streamlit secrets directory and add your firebase key to `.streamlit/secrets.toml`. (Replace `path_to_firebase_key.json` with your path) + + ```python + import toml + import os + + # Create streamlit secrets directory and secrets.toml if it doesn't exist + if not os.path.exists("./.streamlit"): + os.mkdir("./.streamlit") + f = open("./.streamlit/secrets.toml", "x") + f.close() + + output_file = ".streamlit/secrets.toml" + + with open(path_to_firebase_key.json) as json_file: + json_text = json_file.read() + + config = {"firebase": json_text} + toml_config = toml.dumps(config) + + with open(output_file, "w") as target: + target.write(toml_config) + ``` +2. Add this to the top of your file + ```python + with streamlit_analytics.track(firestore_collection_name="data", streamlit_secrets_firestore_key="firebase", firestore_project_name=firestore_project_name): + # or pass the same args to `start_tracking` AND `stop_tracking` + ``` +**Full Example** + ```python + import streamlit as st + import streamlit_analytics + + with streamlit_analytics.track(firestore_collection_name="data", streamlit_secrets_firestore_key="firebase", firestore_project_name=firestore_project_name): + st.text_input("Write something") + st.button("Click me") + ``` + - You can **store analytics results as a json file** with: ```python diff --git a/streamlit_analytics/firestore.py b/streamlit_analytics/firestore.py index ef2ffac..a727679 100644 --- a/streamlit_analytics/firestore.py +++ b/streamlit_analytics/firestore.py @@ -1,13 +1,24 @@ from google.cloud import firestore +from google.oauth2 import service_account +import streamlit as st +import json -def load(counts, service_account_json, collection_name): +def load(counts, service_account_json, collection_name, streamlit_secrets_firestore_key, firestore_project_name): """Load count data from firestore into `counts`.""" - - # Retrieve data from firestore. - db = firestore.Client.from_service_account_json(service_account_json) - col = db.collection(collection_name) - firestore_counts = col.document("counts").get().to_dict() + if streamlit_secrets_firestore_key is not None: + # Following along here https://blog.streamlit.io/streamlit-firestore-continued/#part-4-securely-deploying-on-streamlit-sharing for deploying to Streamlit Cloud with Firestore + key_dict = json.loads(st.secrets[streamlit_secrets_firestore_key]) + creds = service_account.Credentials.from_service_account_info(key_dict) + db = firestore.Client( + credentials=creds, project=firestore_project_name) + col = db.collection(collection_name) + # Change back to "counts" + firestore_counts = col.document("data").get().to_dict() + else: + db = firestore.Client.from_service_account_json(service_account_json) + col = db.collection(collection_name) + firestore_counts = col.document("data").get().to_dict() # Update all fields in counts that appear in both counts and firestore_counts. if firestore_counts is not None: @@ -16,9 +27,18 @@ def load(counts, service_account_json, collection_name): counts[key] = firestore_counts[key] -def save(counts, service_account_json, collection_name): +def save(counts, service_account_json, collection_name, streamlit_secrets_firestore_key, firestore_project_name): """Save count data from `counts` to firestore.""" - db = firestore.Client.from_service_account_json(service_account_json) + if streamlit_secrets_firestore_key is not None: + # Following along here https://blog.streamlit.io/streamlit-firestore-continued/#part-4-securely-deploying-on-streamlit-sharing for deploying to Streamlit Cloud with Firestore + key_dict = json.loads(st.secrets[streamlit_secrets_firestore_key]) + creds = service_account.Credentials.from_service_account_info(key_dict) + db = firestore.Client( + credentials=creds, project=firestore_project_name) + col = db.collection(collection_name) + else: + db = firestore.Client.from_service_account_json(service_account_json) col = db.collection(collection_name) - doc = col.document("counts") + # Change back to "counts" + doc = col.document("data") doc.set(counts) # creates if doesn't exist diff --git a/streamlit_analytics/main.py b/streamlit_analytics/main.py index 442b5ec..4318b0f 100644 --- a/streamlit_analytics/main.py +++ b/streamlit_analytics/main.py @@ -24,9 +24,11 @@ def reset_counts(): counts["total_pageviews"] = 0 counts["total_script_runs"] = 0 counts["total_time_seconds"] = 0 - counts["per_day"] = {"days": [str(yesterday)], "pageviews": [0], "script_runs": [0]} + counts["per_day"] = {"days": [str(yesterday)], "pageviews": [ + 0], "script_runs": [0]} counts["widgets"] = {} - counts["start_time"] = datetime.datetime.now().strftime("%d %b %Y, %H:%M:%S") + counts["start_time"] = datetime.datetime.now().strftime( + "%d %b %Y, %H:%M:%S") reset_counts() @@ -75,7 +77,8 @@ def _track_user(): counts["total_script_runs"] += 1 counts["per_day"]["script_runs"][-1] += 1 now = datetime.datetime.now() - counts["total_time_seconds"] += (now - st.session_state.last_time).total_seconds() + counts["total_time_seconds"] += (now - + st.session_state.last_time).total_seconds() st.session_state.last_time = now if not st.session_state.user_tracked: st.session_state.user_tracked = True @@ -229,8 +232,11 @@ def new_func(label, *args, **kwargs): def start_tracking( verbose: bool = False, firestore_key_file: str = None, - firestore_collection_name: str = "counts", + # Change to "counts" + firestore_collection_name: str = "data", load_from_json: Union[str, Path] = None, + streamlit_secrets_firestore_key: str = None, + firestore_project_name: str = None ): """ Start tracking user inputs to a streamlit app. @@ -241,7 +247,16 @@ def start_tracking( `with streamlit_analytics.track():`. """ - if firestore_key_file and not counts["loaded_from_firestore"]: + if streamlit_secrets_firestore_key is not None and not counts["loaded_from_firestore"]: + firestore.load(counts=counts, service_account_json=None, collection_name=firestore_collection_name, + streamlit_secrets_firestore_key=streamlit_secrets_firestore_key, firestore_project_name=firestore_project_name) + counts["loaded_from_firestore"] = True + if verbose: + print("Loaded count data from firestore:") + print(counts) + print() + + elif firestore_key_file and not counts["loaded_from_firestore"]: firestore.load(counts, firestore_key_file, firestore_collection_name) counts["loaded_from_firestore"] = True if verbose: @@ -332,8 +347,11 @@ def stop_tracking( unsafe_password: str = None, save_to_json: Union[str, Path] = None, firestore_key_file: str = None, - firestore_collection_name: str = "counts", + # Change to "counts" + firestore_collection_name: str = "data", verbose: bool = False, + streamlit_secrets_firestore_key: str = None, + firestore_project_name: str = None ): """ Stop tracking user inputs to a streamlit app. @@ -383,7 +401,15 @@ def stop_tracking( # Save count data to firestore. # TODO: Maybe don't save on every iteration but on regular intervals in a background # thread. - if firestore_key_file: + if streamlit_secrets_firestore_key is not None and firestore_project_name is not None: + if verbose: + print("Saving count data to firestore:") + print(counts) + print() + firestore.save(counts=counts, service_account_json=None, collection_name=firestore_collection_name, + streamlit_secrets_firestore_key=streamlit_secrets_firestore_key, firestore_project_name=firestore_project_name) + + elif streamlit_secrets_firestore_key is None and firestore_project_name is None and firestore_key_file: if verbose: print("Saving count data to firestore:") print(counts) @@ -392,7 +418,7 @@ def stop_tracking( # Dump the counts to json file if `save_to_json` is set. # TODO: Make sure this is not locked if writing from multiple threads. - if save_to_json is not None: + elif streamlit_secrets_firestore_key is None and firestore_project_name is None and save_to_json is not None: with Path(save_to_json).open("w") as f: json.dump(counts, f) if verbose: @@ -410,9 +436,11 @@ def track( unsafe_password: str = None, save_to_json: Union[str, Path] = None, firestore_key_file: str = None, - firestore_collection_name: str = "counts", + firestore_collection_name: str = "data", verbose=False, load_from_json: Union[str, Path] = None, + streamlit_secrets_firestore_key: str = None, + firestore_project_name: str = None ): """ Context manager to start and stop tracking user inputs to a streamlit app. @@ -421,21 +449,37 @@ def track( This also shows the analytics results below your app if you attach `?analytics=on` to the URL. """ - - start_tracking( - verbose=verbose, - firestore_key_file=firestore_key_file, - firestore_collection_name=firestore_collection_name, - load_from_json=load_from_json, - ) + if streamlit_secrets_firestore_key is not None and firestore_project_name is not None: + start_tracking( + verbose=verbose, + firestore_collection_name=firestore_collection_name, + streamlit_secrets_firestore_key=streamlit_secrets_firestore_key, + firestore_project_name=firestore_project_name + ) + + else: + start_tracking( + verbose=verbose, + firestore_key_file=firestore_key_file, + firestore_collection_name=firestore_collection_name, + load_from_json=load_from_json, + ) # Yield here to execute the code in the with statement. This will call the wrappers # above, which track all inputs. yield - stop_tracking( - unsafe_password=unsafe_password, - save_to_json=save_to_json, - firestore_key_file=firestore_key_file, - firestore_collection_name=firestore_collection_name, - verbose=verbose, - ) + if streamlit_secrets_firestore_key is not None and firestore_project_name is not None: + stop_tracking( + firestore_collection_name=firestore_collection_name, + streamlit_secrets_firestore_key=streamlit_secrets_firestore_key, + firestore_project_name=firestore_project_name, + verbose=verbose + ) + else: + stop_tracking( + unsafe_password=unsafe_password, + save_to_json=save_to_json, + firestore_key_file=firestore_key_file, + firestore_collection_name=firestore_collection_name, + verbose=verbose + ) diff --git a/test.py b/test.py new file mode 100644 index 0000000..e17ae43 --- /dev/null +++ b/test.py @@ -0,0 +1,7 @@ +import streamlit as st +import streamlit_analytics +import os + +with streamlit_analytics.track(firestore_collection_name="data", streamlit_secrets_firestore_key="firebase", firestore_project_name=os.environ["CURRI_FIREBASE_PROJECT_NAME"]): + st.text_input("Write something") + st.button("Click me") From ca6e70f33bc1f3c4120e782a379360e59ce3e288 Mon Sep 17 00:00:00 2001 From: Nathan Jones Date: Wed, 8 Mar 2023 11:22:35 -0800 Subject: [PATCH 2/3] Update functions to use st.secrets instead of firestore-key.json --- README.md | 4 ++-- streamlit_analytics/firestore.py | 8 +++----- streamlit_analytics/main.py | 8 +++----- test.py | 7 ------- 4 files changed, 8 insertions(+), 19 deletions(-) delete mode 100644 test.py diff --git a/README.md b/README.md index ac80c83..2a44194 100644 --- a/README.md +++ b/README.md @@ -111,7 +111,7 @@ your app (see image above). ``` 2. Add this to the top of your file ```python - with streamlit_analytics.track(firestore_collection_name="data", streamlit_secrets_firestore_key="firebase", firestore_project_name=firestore_project_name): + with streamlit_analytics.track(firestore_collection_name="counts", streamlit_secrets_firestore_key="firebase", firestore_project_name=firestore_project_name): # or pass the same args to `start_tracking` AND `stop_tracking` ``` **Full Example** @@ -119,7 +119,7 @@ your app (see image above). import streamlit as st import streamlit_analytics - with streamlit_analytics.track(firestore_collection_name="data", streamlit_secrets_firestore_key="firebase", firestore_project_name=firestore_project_name): + with streamlit_analytics.track(firestore_collection_name="counts", streamlit_secrets_firestore_key="firebase", firestore_project_name=firestore_project_name): st.text_input("Write something") st.button("Click me") ``` diff --git a/streamlit_analytics/firestore.py b/streamlit_analytics/firestore.py index a727679..44af2de 100644 --- a/streamlit_analytics/firestore.py +++ b/streamlit_analytics/firestore.py @@ -13,12 +13,11 @@ def load(counts, service_account_json, collection_name, streamlit_secrets_firest db = firestore.Client( credentials=creds, project=firestore_project_name) col = db.collection(collection_name) - # Change back to "counts" - firestore_counts = col.document("data").get().to_dict() + firestore_counts = col.document("counts").get().to_dict() else: db = firestore.Client.from_service_account_json(service_account_json) col = db.collection(collection_name) - firestore_counts = col.document("data").get().to_dict() + firestore_counts = col.document("counts").get().to_dict() # Update all fields in counts that appear in both counts and firestore_counts. if firestore_counts is not None: @@ -39,6 +38,5 @@ def save(counts, service_account_json, collection_name, streamlit_secrets_firest else: db = firestore.Client.from_service_account_json(service_account_json) col = db.collection(collection_name) - # Change back to "counts" - doc = col.document("data") + doc = col.document("counts") doc.set(counts) # creates if doesn't exist diff --git a/streamlit_analytics/main.py b/streamlit_analytics/main.py index 4318b0f..84df938 100644 --- a/streamlit_analytics/main.py +++ b/streamlit_analytics/main.py @@ -232,8 +232,7 @@ def new_func(label, *args, **kwargs): def start_tracking( verbose: bool = False, firestore_key_file: str = None, - # Change to "counts" - firestore_collection_name: str = "data", + firestore_collection_name: str = "counts", load_from_json: Union[str, Path] = None, streamlit_secrets_firestore_key: str = None, firestore_project_name: str = None @@ -347,8 +346,7 @@ def stop_tracking( unsafe_password: str = None, save_to_json: Union[str, Path] = None, firestore_key_file: str = None, - # Change to "counts" - firestore_collection_name: str = "data", + firestore_collection_name: str = "counts", verbose: bool = False, streamlit_secrets_firestore_key: str = None, firestore_project_name: str = None @@ -436,7 +434,7 @@ def track( unsafe_password: str = None, save_to_json: Union[str, Path] = None, firestore_key_file: str = None, - firestore_collection_name: str = "data", + firestore_collection_name: str = "counts", verbose=False, load_from_json: Union[str, Path] = None, streamlit_secrets_firestore_key: str = None, diff --git a/test.py b/test.py deleted file mode 100644 index e17ae43..0000000 --- a/test.py +++ /dev/null @@ -1,7 +0,0 @@ -import streamlit as st -import streamlit_analytics -import os - -with streamlit_analytics.track(firestore_collection_name="data", streamlit_secrets_firestore_key="firebase", firestore_project_name=os.environ["CURRI_FIREBASE_PROJECT_NAME"]): - st.text_input("Write something") - st.button("Click me") From fe15253b4eb6319ee74e76901758fd49472d9169 Mon Sep 17 00:00:00 2001 From: Nathan Jones Date: Fri, 10 Mar 2023 14:22:53 -0800 Subject: [PATCH 3/3] Widget usage displayed in DataFrames instead of a dictionary --- streamlit_analytics/display.py | 14 +++++++++++++- streamlit_analytics/firestore.py | 7 ++++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/streamlit_analytics/display.py b/streamlit_analytics/display.py index 9a8cbbb..7d983bc 100644 --- a/streamlit_analytics/display.py +++ b/streamlit_analytics/display.py @@ -111,7 +111,19 @@ def show_results(counts, reset_callback, unsafe_password=None): """, unsafe_allow_html=True, ) - st.write(counts["widgets"]) + for i in counts["widgets"].keys(): + st.markdown(f"##### `{i}` Widget Usage") + if type(counts["widgets"][i]) == dict: + st.dataframe(pd.DataFrame({ + "widget_name": i, + "selected_value": list(counts["widgets"][i].keys()), + "number_of_interactions": counts["widgets"][i].values() + }).sort_values(by="number_of_interactions", ascending=False)) + else: + st.dataframe(pd.DataFrame({ + "widget_name": i, + "number_of_interactions": counts["widgets"][i] + }, index=[0]).sort_values(by="number_of_interactions", ascending=False)) # Show button to reset analytics. st.header("Danger zone") diff --git a/streamlit_analytics/firestore.py b/streamlit_analytics/firestore.py index 44af2de..e8a5b6e 100644 --- a/streamlit_analytics/firestore.py +++ b/streamlit_analytics/firestore.py @@ -34,9 +34,14 @@ def save(counts, service_account_json, collection_name, streamlit_secrets_firest creds = service_account.Credentials.from_service_account_info(key_dict) db = firestore.Client( credentials=creds, project=firestore_project_name) - col = db.collection(collection_name) else: db = firestore.Client.from_service_account_json(service_account_json) + col = db.collection(collection_name) doc = col.document("counts") + # Make sure the keys of nested dictionaries are str type + for subdict in counts["widgets"]: + if type(counts["widgets"][subdict]) == dict: + counts["widgets"][subdict] = { + str(k): v for k, v in counts["widgets"][subdict].items()} doc.set(counts) # creates if doesn't exist