Skip to content

Commit

Permalink
De-experimentalize st.fragment (streamlit#9019)
Browse files Browse the repository at this point in the history
We've had a few bugs filed and additional features requested for `@st.experimental_fragment`, but
for the most part, the feature seems to work quite well / we haven't seen much evidence that any drastic
changes to the API are required. While we may still make some tweaks to the feature going forward, it
does seem ok at this point to drop the `experimental_` prefix for the decorator to make it an officially
supported part of the Streamlit API!

This PR de-experimentalizes the `@st.fragment` decorator, and in the process it makes the following
changes to the feature.

* `st.rerun(scope="fragment")` can now be called from within a fragment to rerun only that fragment.
   We also added `st.rerun(scope="app")`, which functions the same way as calling `st.rerun()` without
   an argument does today.
* fragments can now be nested, which includes allowing developers to call fragments from within a dialog.
   Note: it is still prohibited to create a dialog from within a dialog.
* it is now explicitly prohibited for a fragment to write a widget outside of the fragment.
* various bugfixes, including:
   * Exceptions raised from within a fragment can no longer interrupt other "simultaneously" running
      fragments.
   * Exceptions raised from within a fragment now print their error messages inside of the fragment's
      container rather than "globally" in the app.
   * We no longer incorrectly reraise `RuntimeError`s when a `KeyError` is raised within a fragment.
      * This may not completely get rid of instances where we're seeing users run into `RuntimeError`s
         with the message `"Could not find fragment with id <fragment_id>"`, but I do expect a substantial
         number of them to go away.

Closes streamlit#8635
Closes streamlit#8494
Closes streamlit#8591

---------

Co-authored-by: Benjamin Räthlein <[email protected]>
  • Loading branch information
vdonato and raethlein authored Jul 15, 2024
1 parent ddd39d0 commit 4e145a2
Show file tree
Hide file tree
Showing 58 changed files with 1,241 additions and 298 deletions.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion e2e_playwright/multipage_apps_v2/mpa_v2_basics.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ def page_9():
def page_10():
st.header("Page 10")

@st.experimental_fragment
@st.fragment
def get_input():
st.text_input("Some input")
if st.button("Submit"):
Expand Down
2 changes: 1 addition & 1 deletion e2e_playwright/st_altair_chart_basic_select.py
Original file line number Diff line number Diff line change
Expand Up @@ -272,7 +272,7 @@ def on_selection():
st.header("Selections in fragment:")


@st.experimental_fragment
@st.fragment
def test_fragment():
selection = st.altair_chart(
histogram_point,
Expand Down
2 changes: 1 addition & 1 deletion e2e_playwright/st_color_picker.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ def callback():
)


@st.experimental_fragment()
@st.fragment
def test_fragment():
selection = st.color_picker("Fragment Color Picker")
st.write("color_picker-in-fragment selection:", str(selection))
Expand Down
2 changes: 1 addition & 1 deletion e2e_playwright/st_dataframe_selections.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ def on_selection():
st.header("Selections in fragment:")


@st.experimental_fragment()
@st.fragment
def test_fragment():
selection = st.dataframe(
df,
Expand Down
15 changes: 15 additions & 0 deletions e2e_playwright/st_dialog.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import pandas as pd

import streamlit as st
from streamlit.runtime.scriptrunner.script_run_context import get_script_run_ctx


@st.experimental_dialog("Test Dialog with Images")
Expand Down Expand Up @@ -90,6 +91,19 @@ def dialog_in_sidebar():
dialog_in_sidebar()


@st.experimental_dialog("Submit-button Dialog")
def submit_button_dialog():
st.write("This dialog has a submit button.")
st.write(f"Fragment Id: {get_script_run_ctx().current_fragment_id}")

if st.button("Submit", key="dialog6-btn"):
st.rerun()


if st.button("Open submit-button Dialog"):
submit_button_dialog()


@st.experimental_dialog("Level2 Dialog")
def level2_dialog():
st.write("Second level dialog")
Expand All @@ -98,6 +112,7 @@ def level2_dialog():
@st.experimental_dialog("Level1 Dialog")
def level1_dialog():
st.write("First level dialog")
st.write(f"Fragment Id: {get_script_run_ctx().current_fragment_id}")
level2_dialog()


Expand Down
79 changes: 67 additions & 12 deletions e2e_playwright/st_dialog_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from playwright.sync_api import Page, expect

from e2e_playwright.conftest import ImageCompareFunction, wait_for_app_run
from e2e_playwright.shared.app_utils import get_markdown

modal_test_id = "stModal"

Expand Down Expand Up @@ -46,6 +47,14 @@ def open_dialog_with_internal_error(app: Page):
app.get_by_role("button").filter(has_text="Open Dialog with Key Error").click()


def open_nested_dialogs(app: Page):
app.get_by_role("button").filter(has_text="Open Nested Dialogs").click()


def open_submit_button_dialog(app: Page):
app.get_by_role("button").filter(has_text="Open submit-button Dialog").click()


def click_to_dismiss(app: Page):
# Click somewhere outside the close popover container:
app.keyboard.press("Escape")
Expand Down Expand Up @@ -74,7 +83,7 @@ def test_dialog_closes_properly(app: Page):


def test_dialog_dismisses_properly(app: Page):
"""Test that dialog is dismissed properly after clicking on modal close (= dismiss)."""
"""Test that dialog is dismissed properly after clicking on close (= dismiss)."""
open_dialog_with_images(app)
wait_for_app_run(app)
main_dialog = app.get_by_test_id(modal_test_id)
Expand All @@ -86,7 +95,8 @@ def test_dialog_dismisses_properly(app: Page):
expect(main_dialog).to_have_count(0)


# on webkit this test was flaky and manually reproducing the flaky error did not work, so we skip it for now
# on webkit this test was flaky and manually reproducing the flaky error did not work,
# so we skip it for now
@pytest.mark.skip_browser("webkit")
def test_dialog_reopens_properly_after_dismiss(app: Page):
"""Test that dialog reopens after dismiss."""
Expand All @@ -98,8 +108,8 @@ def test_dialog_reopens_properly_after_dismiss(app: Page):

main_dialog = app.get_by_test_id(modal_test_id)

# sometimes the dialog does not seem to open in the test, so retry opening it by clicking on it.
# if it does not open after the second attempt, fail the test.
# sometimes the dialog does not seem to open in the test, so retry opening it by
# clicking on it. if it does not open after the second attempt, fail the test.
if main_dialog.count() == 0:
app.wait_for_timeout(100)
open_dialog_without_images(app)
Expand Down Expand Up @@ -164,7 +174,7 @@ def test_fullscreen_is_disabled_for_dialog_elements(app: Page):


def test_actions_for_dialog_headings(app: Page):
"""Test that headings within the dialog show the tooltip icon but not the link icon."""
"""Test that dialog headings show the tooltip icon but not the link icon."""
open_headings_dialogs(app)
wait_for_app_run(app)
main_dialog = app.get_by_test_id(modal_test_id)
Expand All @@ -188,7 +198,8 @@ def test_dialog_displays_correctly(app: Page, assert_snapshot: ImageCompareFunct
open_dialog_without_images(app)
wait_for_app_run(app)
dialog = app.get_by_role("dialog")
# click on the dialog title to take away focus of all elements and make the screenshot stable. Then hover over the button for visual effect.
# click on the dialog title to take away focus of all elements and make the
# screenshot stable. Then hover over the button for visual effect.
dialog.locator("div", has_text="Simple Dialog").click()
submit_button = dialog.get_by_test_id("stButton")
expect(submit_button).to_be_visible()
Expand All @@ -202,22 +213,26 @@ def test_largewidth_dialog_displays_correctly(
open_largewidth_dialog(app)
wait_for_app_run(app)
dialog = app.get_by_role("dialog")
# click on the dialog title to take away focus of all elements and make the screenshot stable. Then hover over the button for visual effect.
# click on the dialog title to take away focus of all elements and make the
# screenshot stable. Then hover over the button for visual effect.
dialog.locator("div", has_text="Large-width Dialog").click()
submit_button = dialog.get_by_test_id("stButton")
expect(submit_button).to_be_visible()
submit_button.get_by_test_id("baseButton-secondary").hover()
assert_snapshot(dialog, name="st_dialog-with_large_width")


# its enough to test this on one browser as showing the error inline is more a backend functionality than a frontend one
# its enough to test this on one browser as showing the error inline is more a backend
# functionality than a frontend one
@pytest.mark.only_browser("chromium")
def test_dialog_shows_error_inline(app: Page, assert_snapshot: ImageCompareFunction):
"""Additional check to the unittests we have to ensure errors thrown during the main script execution (not a fragment-only rerun) are rendered within the dialog."""
"""Additional check to the unittests we have to ensure errors thrown during the main
script execution (not a fragment-only rerun) are rendered within the dialog."""
open_dialog_with_internal_error(app)
wait_for_app_run(app)
dialog = app.get_by_role("dialog")
# click on the dialog title to take away focus of all elements and make the screenshot stable. Then hover over the button for visual effect.
# click on the dialog title to take away focus of all elements and make the
# screenshot stable. Then hover over the button for visual effect.
dialog.locator("div", has_text="Dialog with error").click()
expect(dialog.get_by_text("TypeError")).to_be_visible()
assert_snapshot(dialog, name="st_dialog-with_inline_error")
Expand All @@ -231,17 +246,57 @@ def test_sidebar_dialog_displays_correctly(
dialog = app.get_by_role("dialog")
submit_button = dialog.get_by_test_id("stButton")
expect(submit_button).to_be_visible()
# ensure focus of the button to avoid flakiness where sometimes snapshots are made when the button is not in focus
# ensure focus of the button to avoid flakiness where sometimes snapshots are made
# when the button is not in focus
submit_button.get_by_test_id("baseButton-secondary").hover()
assert_snapshot(dialog, name="st_dialog-in_sidebar")


def test_nested_dialogs(app: Page):
"""Test that st.dialog may not be nested inside other dialogs."""
app.get_by_text("Open Nested Dialogs").click()
open_nested_dialogs(app)
wait_for_app_run(app)
exception_message = app.get_by_test_id("stException")

expect(exception_message).to_contain_text(
"StreamlitAPIException: Dialogs may not be nested inside other dialogs."
)


# on webkit this test was flaky and manually reproducing the flaky error did not work,
# so we skip it for now
@pytest.mark.skip_browser("webkit")
def test_dialogs_have_different_fragment_ids(app: Page):
"""Test that st.dialog may not be nested inside other dialogs."""
open_submit_button_dialog(app)
wait_for_app_run(app)
large_width_dialog_fragment_id = get_markdown(app, "Fragment Id:").text_content()
dialog = app.get_by_role("dialog")
submit_button = dialog.get_by_test_id("stButton")
expect(submit_button).to_be_visible()
submit_button.get_by_test_id("baseButton-secondary").click()
wait_for_app_run(app)

open_nested_dialogs(app)
wait_for_app_run(app)
nested_dialog_fragment_id = get_markdown(app, "Fragment Id:").text_content()
exception_message = app.get_by_test_id("stException")
expect(exception_message).to_contain_text(
"StreamlitAPIException: Dialogs may not be nested inside other dialogs."
)
click_to_dismiss(app)
# wait after dismiss so that we can open the next dialog
app.wait_for_timeout(200)
expect(app.get_by_test_id(modal_test_id)).not_to_be_attached()
open_submit_button_dialog(app)
wait_for_app_run(app)
dialog = app.get_by_role("dialog")
submit_button = dialog.get_by_test_id("stButton")
expect(submit_button).to_be_visible()
submit_button.get_by_test_id("baseButton-secondary").click()
wait_for_app_run(app)

exception_message = app.get_by_test_id("stException")
expect(exception_message).not_to_be_attached()

assert large_width_dialog_fragment_id != nested_dialog_fragment_id
46 changes: 0 additions & 46 deletions e2e_playwright/st_experimental_fragment_multiple_fragments_test.py

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
# return values. We also don't test the camera_input, data_editor, and file_uploader
# widgets as well as custom components here due to the disproportionate amount of work
# required to do so.
@st.experimental_fragment
@st.fragment
def my_big_fragment():
st.button("a button")
st.download_button("a download button", b"")
Expand Down
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
"""


@st.experimental_fragment
@st.fragment
def parse_and_exec(response):
code_match = re.search(r"```python\n(.*)\n```", response, re.DOTALL)
if code_match:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
}


@st.experimental_fragment
@st.fragment
def get_location():
with st.container(border=True):
st.subheader("Enter your location")
Expand Down
41 changes: 41 additions & 0 deletions e2e_playwright/st_fragment_mixed_execution_flow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Copyright (c) Streamlit Inc. (2018-2022) Snowflake Inc. (2022-2024)
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import time
from uuid import uuid4

import streamlit as st

if "sleep_time" not in st.session_state:
st.session_state["sleep_time"] = 0
sleep_time = st.session_state["sleep_time"]


@st.fragment
def my_fragment(n):
with st.container(border=True):
st.button("rerun this fragment", key=n)
st.write(f"uuid in fragment {n}: {uuid4()}")
# sleep here so that we have time to react to the flow
# and trigger buttons etc. before the fragment is finished
# and the next starts to render
time.sleep(sleep_time)


my_fragment(1)
my_fragment(2)
my_fragment(3)

st.session_state["sleep_time"] = 3
st.button("Full app rerun")
Loading

0 comments on commit 4e145a2

Please sign in to comment.