Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add st.experimental_(get|set)_query_params capability #1169

Merged
merged 1 commit into from Jul 27, 2020

Conversation

zhaoooyue
Copy link
Contributor

@zhaoooyue zhaoooyue commented Mar 2, 2020

Issue: Issue #798 #302 #1098 This PR is proposing a simple change to add capability to set and get current query strings from browser URL in user's browser tab (without triggering a navigation event!). By introducing this feature to st.experimental namespace, a Streamlit user can easily bookmark an application using query strings and reopen the application else where and render all states using the query strings!

Demo
experimental_qs
Feature:
1️⃣ User can call st.experimental_set_query_params => to set query params for current browser tab

Example

2️⃣ User can call st.experimental_get_query_params => _to get the current query strings as a dict {"checkbox1": "true", "multiselect2=2"}
Example Usage from above demo

import streamlit as st

st.title("Experimental: Play with Query Strings!")

app_state = st.experimental_get_query_params()
app_state = {k: v[0] if isinstance(v, list) else v for k, v in app_state.items()} # fetch the first item in each query string as we don't have multiple values for each query string key in this example

# [1] Checkbox
default_checkbox = eval(app_state["checkbox"]) if "checkbox" in app_state else False
checkbox1 = st.checkbox("Are you really happy today?", key="checkbox1", value = default_checkbox)
app_state["checkbox"] = checkbox1
st.experimental_set_query_params(**app_state)
st.markdown("")


# [2] Radio
radio_list = ['Eat', 'Sleep', 'Both']
default_radio = int(app_state["radio"]) if "radio" in app_state else 0
genre = st.radio("What are you doing at home during quarantine?", radio_list, index = default_radio)
if genre:
    app_state["radio"] = radio_list.index(genre)
    st.experimental_set_query_params(**app_state)
st.markdown("")


# [3] Text Input
default_title = app_state["movie"] if "movie" in app_state else ""
title = st.text_input('Movie title', value = default_title)
app_state["movie"] = title
st.experimental_set_query_params(**app_state)

Contribution License Agreement

By submiting this pull request you agree that all contributions to this project are made under the Apache 2.0 license.

@zhaoooyue zhaoooyue requested a review from a team as a code owner March 2, 2020 13:35
@tvst
Copy link
Contributor

tvst commented Mar 6, 2020

Hey @zhaoooyue , just (belatedly) following up from our meeting a few days ago: we'll have a meeting on our end about this proposal and will get back to you.

Thanks for sending the PR!

@zhaoooyue
Copy link
Contributor Author

@tvst Thanks Thiago! let me know if any further changes are needed, i.e. tests.

@k4r1
Copy link
Contributor

k4r1 commented Mar 19, 2020

Maybe get_query_string and set_query_string might be a bit better for the name? That way we don't get in the way of any future plans to allow for different routes within the application?

@orieger
Copy link

orieger commented Mar 25, 2020

Hey streamlit Team, thanks for this marvelous application - helps so much.
And thank you @zhaoooyue for this useful additional feature!
One question though: Is the following behaviour intended? Seems like you can't call the app with query params and then retrieve these params with st.get_url().

streamlit_get_url

Code:

import streamlit as st
url = st.get_url()
url

@treuille
Copy link
Contributor

Hey Karl and @zhaoooyue:

We hope this email finds you well and safe during this unprecedented time!

How we're thinking about experimental features in general

We appreciate your innovative proposal and it prompted a lot of discussion! 🙏🏻

We want to make sure that we're responsive to the needs and efforts of the community. We are currently planning to release a new st.experimental package (link - more details coming soon) in order to create a faster and more predictable path for new features and ideas to become part of Streamlit. We also ask that groups keep in mind our contribution guidelines. We would greatly appreciate your feedback on these guidelines and on this new process for experimental features.

Specific thoughts on this proposal

In thinking about this proposal our goals were to:

  1. Help unblock your team. (We really appreciate Oak North's support for Streamlit.)
  2. Rationalize this idea with our roadmap, and in particular with:
    1. Our plans for state (which somewhat conflict with your proposal)
    2. A general proposal which has been floating around to encode widget state in URL parameters.

Specific recommendations for this PR

To combine these goals we kindly and respectfully ask that you:

  1. Add st.set_url and st.get_url to the new st.experimental package.

This will also allow us to solicit more feedback from the community about this proposal.

We apologize for the delay getting back to you on this. We greatly appreciate Oak North's support for Streamlit! ❤️

@zhaoooyue zhaoooyue force-pushed the develop branch 3 times, most recently from 568766f to 6346b05 Compare March 31, 2020 12:55
@zhaoooyue
Copy link
Contributor Author

Hey streamlit Team, thanks for this marvelous application - helps so much.
And thank you @zhaoooyue for this useful additional feature!
One question though: Is the following behaviour intended? Seems like you can't call the app with query params and then retrieve these params with st.get_url().

streamlit_get_url

Code:

import streamlit as st
url = st.get_url()
url

Hi @orieger - yes this behaviour is expected as the st.get_url can only retrieve the url which is modified by st.set_url . In that sense, it is not capable of reading arbitrary url which are not set by st.set_url.

As you can see this is currently an experimental feature, and the design behind this is to not block other routes to be introduced by future Streamlit features. This is also the reason why the current design is not causing a redirect in the browser.

However I certainly feel the need of your requirement - similar to @tvst mentioned earlier

.....when a user loads a Streamlit URL, the app would also automatically check the params to set up its widgetState before making a rerun request to the server...

These are two different routes to handle this new capability - let's wait Streamlit team's reply on this on a possible design which is also in line with future features 😄

@zhaoooyue
Copy link
Contributor Author

@treuille Thanks for looking at the PR!

As requested, I have move the two new APIs to st.experimental namespace and updated the description to include the updated code snippets to utilise this feature.

You were also mentioning that there is somewhat conflicts between this PR and the team's Plan for State . Appreciate it if more info/feedback can be provided such I can incorporate this into the plan, or even re-vamp it!

Not last - thanks for this amazing tool, as always 💯

@zhaoooyue zhaoooyue changed the title Add st.get_url and st.set_url capability Add st.experimental.get_url and st.experimental.set_url capability Mar 31, 2020
@zhaoooyue zhaoooyue changed the title Add st.experimental.get_url and st.experimental.set_url capability Add st.experimental.(get_url|set_url) capability Mar 31, 2020
@tvst
Copy link
Contributor

tvst commented Apr 1, 2020

Hey, apologies for the delay in reviewing! Will look at this today.

Copy link
Contributor

@tvst tvst left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the PR! I left a few requests for changes, but the main issues are:

  1. I think we should probably limit this feature to setting/getting only the query string. Would the code with this change still be useful to you? The rationale:

    1. The query string is most likely the thing the user is interested in anyway. And if we allowed getting/setting the whole URL users would have to do some URL-parsing acrobatics to get to it.
    2. It doesn't make sense to "set" the hostname or protocol, since those don't change for a given app.
    3. If you could read the host, protocol and base path it would be very easy to accidentally write an app that only works when run in a certain setup, but breaks as soon as you move it elsewhere.

    And if we go this route, we can change get_url / set_url to return/accept dicts instead of strings. So the user's life would be much easier 😃

    I added some comments about this throughout the review.

  2. The code in get_url/set_url is currently fairly prototype-quality... but that's OK for now, since this is experimental code. For this very reason, though, I'd rather not modify other parts of the app, like ReportContext, unless we come up with a cleaner architecture. So it's preferable to simply monkey-patch the context in set_url, for example.

    For the same reason, whatever other parts of the code must be modified (because there's no way around it), we should mark them with a comment saying "This is used in st.experimenta;.get_url".

    I added some comments about this throughout the review, too.

  3. Please write tests 😉

* @param newUrl string
*/
handleNewUrlChange = (newUrl: string): void => {
window.history.pushState({}, "", newUrl ? newUrl : "/")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's likely a bug here: the URL should be prefixed with the basePath from lib.UriUtil.getWindowBaseUriParts(), since people can host apps under non-root paths too.

And following up on item 1 from the root comment in this PR, when you change the code to only allow setting the query string, then the protocol, host, and basePath should all come from getWindowBaseUriParts.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this has been changed to append queryString using window.history.pushState such the browser will not be redirected or refreshed.

this will only be called upon user calls st.experimental.set_query_string(some_value). on server side I have added validation to ensure frontend will only receive valid query strings, so we should be good here. Proof: Click me

frontend/src/App.tsx Outdated Show resolved Hide resolved
lib/streamlit/ReportSession.py Outdated Show resolved Hide resolved
lib/streamlit/__init__.py Outdated Show resolved Hide resolved
proto/streamlit/proto/BackMsg.proto Outdated Show resolved Hide resolved
lib/streamlit/__init__.py Outdated Show resolved Hide resolved
proto/streamlit/proto/BackMsg.proto Outdated Show resolved Hide resolved
lib/streamlit/__init__.py Outdated Show resolved Hide resolved
lib/streamlit/__init__.py Outdated Show resolved Hide resolved
lib/streamlit/__init__.py Outdated Show resolved Hide resolved
@zhaoooyue
Copy link
Contributor Author

thanks @tvst ! I will update the PR to address your comments accordingly

@zhaoooyue zhaoooyue force-pushed the develop branch 5 times, most recently from 11d1ea9 to 5e90067 Compare April 9, 2020 15:45
@tvst
Copy link
Contributor

tvst commented Apr 10, 2020

Hey @zhaoooyue , LMK when you need another review.

@zhaoooyue zhaoooyue changed the title Add st.experimental.(get_url|set_url) capability Add st.experimental.(get|set)_query_string capability Apr 10, 2020
@zhaoooyue
Copy link
Contributor Author

zhaoooyue commented Apr 12, 2020

Hi @tvst - thanks for the thorough review lat time! I have updated the PR addressing your comments (except tests in progress). Hopefully it has now jumped to experimental-quality from prototype-quality 😉 . The changes include

  • Change to set/get query string everywhere with urllib parse_qs and urlencode . Now the call to st.experimental.(get|set)_query_string will operate on dict as you suggested.
  • No more modification on ReportSession and ReportContext. query strings are attached via monkey-patch approach.
  • Update comments/docstring based on streamline standard (including explicitly stating this is used in st.experimental.get_query_string )
  • Add retry logic in get_query_string (thanks @orieger for finding the issue)

I have also updated the PR description with this changes as well as a demo on how to bookmark an Streamlit app with query strings (tag #302 and this).

@tvst Another review will be greatly appreciated! I will read about Streamlit unit/e2e testing approaches and add tests meanwhile.

Copy link
Collaborator

@monchier monchier left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Few questions in this review. Also, there are no tests. Please add tests. At the very least, I would suggest adding unit tests. I will be waiting for the tests to be added.

A high level concern is that we are keeping the query string as a piece of state attached to the session. This can be a problem since this is a piece of info stored in the server and in the browser and it could go out of sync. Any comments? Also not a big fan of the retry loop in the get_query_string. I haven't been part of the architectural discussion for this, but can you let me know if this has been considered? I wonder there is a way of doing this that does not require storing the query string in the session on the server.

lib/streamlit/__init__.py Outdated Show resolved Hide resolved
lib/streamlit/server/Server.py Outdated Show resolved Hide resolved
lib/streamlit/__init__.py Outdated Show resolved Hide resolved
lib/streamlit/__init__.py Outdated Show resolved Hide resolved
frontend/src/App.tsx Outdated Show resolved Hide resolved
frontend/src/App.tsx Outdated Show resolved Hide resolved
lib/streamlit/__init__.py Outdated Show resolved Hide resolved
@benlindsay
Copy link

Can someone point me to a session state implementation that's compatible with the latest nightly release? (0.64.1.dev20200731 as of now) I tried this one and got this error:

ModuleNotFoundError: No module named 'streamlit.server.Server'
Traceback:

File "/----------/streamlit-tabs/venv/lib/python3.8/site-packages/streamlit/script_runner.py", line 324, in _run_script
    exec(code, module.__dict__)
File "/----------/streamlit-tabs/app.py", line 2, in <module>
    import st_state_patch
File "/----------/streamlit-tabs/st_state_patch.py", line 54, in <module>
    from streamlit.server.Server import Server

I also tried this one and got this error:

ModuleNotFoundError: No module named 'streamlit.ReportThread'
Traceback:

File "/-----------/streamlit-tabs/venv/lib/python3.8/site-packages/streamlit/script_runner.py", line 324, in _run_script
    exec(code, module.__dict__)
File "/-----------/streamlit-tabs/app.py", line 3, in <module>
    import SessionState
File "/-----------/streamlit-tabs/SessionState.py", line 18, in <module>
    import streamlit.ReportThread as ReportThread

@vhoulbreque
Copy link

They have been renamed to streamlit.server.server and streamlit.report_thread.

@benlindsay
Copy link

benlindsay commented Aug 2, 2020

Thanks @vinzeebreak! I'll give that a shot.

Update: making those changes works. thanks!

@jaystary
Copy link

jaystary commented Aug 5, 2020

Was this feature removed again in the latest nightly?
Aside from that i noticed some issues. If i changed and saved my script after the second time, the query parameters would be none although they still are present in the URL.

Also .experimental_set_query_params(dict) wouldnt allow to take parameters...

This happend with dev20200801

@benlindsay
Copy link

Also .experimental_set_query_params(dict) wouldnt allow to take parameters...

Is it supposed to take a dict as an argument? For me passing **dict instead of dict works

@jaystary
Copy link

jaystary commented Aug 5, 2020

I think something broke with the latest nightly because the entire feature is gone.
Also- the change from streamlit.server.Server to streamlit.server.server seems to have reverted

@randyzwitch
Copy link
Contributor

@benlindsay or @jaystary can you split this off into a new issue? I want to make sure this doesn't get lost if people aren't checking a closed PR

@benlindsay
Copy link

I'm not sure it should be a new issue. I just tried with the dev20200801 and the feature is still there, and streamlit.server.server did not revert. @jaystary, my guess is that you installed the nightly version in one environment, then ran streamlit from another environment, which is a super easy mistake to make. I did something similar within the past week or two. Maybe run which pip and which streamlit, make sure those executables are in the same environment, run pip list and verify you have the version of streamlit that you think you have, then run streamlit?

@karriebear
Copy link
Contributor

For completeness, when running the demo, I noticed a bit of a hiccup.
ezgif com-optimize

This is because of an off-by-one error, which arises from Streamlit's execution model (i.e. no callbacks) and cannot be addressed until we make some changes to that.

A breakdown of what's happening:

  1. User runs the app for the first time. "Eat" is selected by default.
  2. User clicks on "Sleep". This reruns the script from top to bottom.
  3. During that rerun, when the execution reaches this line
    app_state = st.experimental_get_query_params()
    the query params in the URL bar are still set to "Eat" (because the code that sets to "Sleep" hasn't executed yet).
  4. This means that default_radio will be set to "Eat" and the code that draws the radio buttons will re-set the selected button to "Eat".
# default_radio is set back to "Eat"
# even though you clicked on "Sleep" before.
default_radio = int(app_state["radio"]) if "radio" in app_state else 0
genre = st.radio("What are you doing at home during quarantine?", radio_list, index = default_radio)
# genre is set to "Sleep", though.

But, interestingly, that code also checks the widget state that came from the browser and returns "Sleep" due to that. So now genre is "Sleep".
5. Now we reach the lines below

app_state["radio"] = radio_list.index(genre)
st.experimental_set_query_params(**app_state)

At this point the query params are set with radio=2 ("Sleep"). Leading to an inconsistent state!

@karriebear
Copy link
Contributor

karriebear commented Aug 12, 2020

Looks like using @tvst's gist for SessionState will resolve the funny behavior. Here's an updated demo:

import streamlit as st
import SessionState

query_params = st.experimental_get_query_params()
app_state = st.experimental_get_query_params()

first_query_params = session_state.first_query_params
session_state = SessionState.get(first_query_params=query_params)

radio_list = ['Eat', 'Sleep', 'Both']

# The trick here is you can't change the default index based on query params on every run!
# The *only time* you do that is on the *first* run.
default_index = eval(first_query_params["radio"][0]) if "radio" in app_state else 0

genre = st.radio("What are you doing at home during quarantine?", radio_list, index=default_index)
app_state["radio"] = radio_list.index(genre)

st.experimental_set_query_params(**app_state)

@mappingvermont
Copy link

This is awesome!! Thanks so much @zhaoooyue @tvst @karriebear 👏 👏 👏

@WilberDelbrison
Copy link

Hi All, thanks for this incredible library!
This function to get the parameters is based on session information? I'm using my App with multiple users, and with each new session, I'm not able to read the parameters with st.experimental_get_query_params().
Thanks in advance

@karriebear
Copy link
Contributor

karriebear commented Sep 8, 2020

@WilberDelbrison looking through the PR quickly, it mentions using a client state so it should be based on per user session. If this is still an issue, could you please open a new issue with a reproducible code sample for us to look at?

@vini-2907
Copy link

vini-2907 commented Sep 10, 2020

Issue: Issue #798 #302 #1098 This PR is proposing a simple change to add capability to set and get current query strings from browser URL in user's browser tab (without triggering a navigation event!). By introducing this feature to st.experimental namespace, a Streamlit user can easily bookmark an application using query strings and reopen the application else where and render all states using the query strings!

Demo
experimental_qs
Feature:
1️⃣ User can call st.experimental_set_query_params => to set query params for current browser tab

Example

* If user’s current browser tab is http://localhost:3000 and user called `st.experimental_set_query_params( checkbox1=True, multiselect2=2 )`, the user’s browser tab will become http://localhost:3000/?checkbox1=true&multiselect2=2 and this will NOT cause a redirect action.

2️⃣ User can call st.experimental_get_query_params => _to get the current query strings as a dict {"checkbox1": "true", "multiselect2=2"}
Example Usage from above demo

import streamlit as st

st.title("Experimental: Play with Query Strings!")

app_state = st.experimental_get_query_params()
app_state = {k: v[0] if isinstance(v, list) else v for k, v in app_state.items()} # fetch the first item in each query string as we don't have multiple values for each query string key in this example

# [1] Checkbox
default_checkbox = eval(app_state["checkbox"]) if "checkbox" in app_state else False
checkbox1 = st.checkbox("Are you really happy today?", key="checkbox1", value = default_checkbox)
app_state["checkbox"] = checkbox1
st.experimental_set_query_params(**app_state)
st.markdown("")


# [2] Radio
radio_list = ['Eat', 'Sleep', 'Both']
default_radio = int(app_state["radio"]) if "radio" in app_state else 0
genre = st.radio("What are you doing at home during quarantine?", radio_list, index = default_radio)
if genre:
    app_state["radio"] = radio_list.index(genre)
    st.experimental_set_query_params(**app_state)
st.markdown("")


# [3] Text Input
default_title = app_state["movie"] if "movie" in app_state else ""
title = st.text_input('Movie title', value = default_title)
app_state["movie"] = title
st.experimental_set_query_params(**app_state)

Contribution License Agreement

By submiting this pull request you agree that all contributions to this project are made under the Apache 2.0 license.

can You pls help me do this how to do it how to install am very new to streamlit with clear steps it would be more helpful
how can I install st.experimental

@karriebear
Copy link
Contributor

@vini-2907 there's no need to install anything else. Anything under the experimental namespace are available in streamlit just prefaced with experimental_. You should be able to run the code sample as is. If you need additional help, please head over to our forums. We have a great community ready to help!

You can learn more about our experimental namespace in our docs

@hfwittmann
Copy link

hfwittmann commented Oct 19, 2020

Looks like using @tvst's gist for SessionState will resolve the funny behavior. Here's an updated demo:

import streamlit as st
import SessionState

query_params = st.experimental_get_query_params()
app_state = st.experimental_get_query_params()

first_query_params = session_state.first_query_params
session_state = SessionState.get(first_query_params=query_params)

radio_list = ['Eat', 'Sleep', 'Both']

# The trick here is you can't change the default index based on query params on every run!
# The *only time* you do that is on the *first* run.
default_index = eval(first_query_params["radio"][0]) if "radio" in app_state else 0

genre = st.radio("What are you doing at home during quarantine?", radio_list, index=default_index)
app_state["radio"] = radio_list.index(genre)

st.experimental_set_query_params(**app_state)

Works for me except the order of two lines has to be changed like so:

from this:

first_query_params = session_state.first_query_params
session_state = SessionState.get(first_query_params=query_params)

to that:

session_state = SessionState.get(first_query_params=query_params)
first_query_params = session_state.first_query_params

And for completeness' sake: for the SessionState I had to use https://gist.github.com/FranzDiebold/898396a6be785d9b5ca6f3706ef9b0bc
which is an updated version referenced in @tvst's gist for SessionState

@quantoid
Copy link

quantoid commented May 19, 2021

@karriebear thanks for the explanation (above) but I don't understand why the default from the URL parameters is being used once the dashboard has already loaded. The API docs suggest that the default index is only used when the page is loaded, and not when redrawn in response to widget changes:

index (int) – The index of the preselected option on first render.

Shouldn't all widgets always show and return any value that comes from the browser, and only use the given default if none?

@franekp
Copy link

franekp commented Jun 21, 2022

Hi Everyone,

Thanks for adding these powerful capabilities to Streamlit! Building on your work, I made a library of url-aware versions of input controls that automatically sync their state with the url query string: streamlit-permalink.

Hopefully some day these ideas will find their way into Streamlit core and such external libraries would no longer be needed :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet