Repository: streamlit/streamlit
Pull request: Allow set_page_config after callbacks
fixes #3410 

We only allow set_page_config to be called once per script run, and we do not allow any other st calls to execute prior to it. Callbacks changed things, and st calls are now allowed to run before set_page_config.

This change ensures the following:
- callbacks can call st commands without throwing an exception for `set_page_config`
- `set_page_config` remains the first st command allowed when the script runs
- In any case, we prevent `set_page_config` from being called twice.

Problem statement:
StreamlitAPIException : set_page_config() can only be called once per app, and must be called as the first Streamlit command in your script.
I am facing this issue when trying to set page config in a multi-page app. However, I have an idea of setting the default layout to wide which will solve my issue but how to do so?

Hints from issue discussion:
I was facing a similar issue, 
Below solved the issue is my case:

in your `main.py` file, add the page config to the state object.

```python
import streamlit as st

from SessionState import _get_state


state = _get_state()

state.page_config = st.set_page_config(
    page_title="BPJV SI Database Manager test",
    layout="wide",
    initial_sidebar_state="expanded",
)

# rest of the code

state.sync()
```
for state object see [this](https://gist.github.com/tvst/036da038ab3e999a64497f42de966a92) gist 
i am facing this issue when use streamlit official state (0.84) for multi page
Hey @afblaze94 and @muhammadAgfian96 ! Thanks for reaching out to us. I am not sure I understand the problem. Are you asking to be able to set different configs based on the page?
I have the same issue. Actually, it is because my callbacks are executed at the beginning of the script and so, st_page_config is no longer the first Streamlit instruction. As described, st_page_config must be the very first st instruction but it's not always possible when callbacks contain st codes.
Totally understand. I have put this in front of our Product team to take a look.

Commit message: combine conditions

Patch to review:
diff --git a/e2e/scripts/st_set_page_config.py b/e2e/scripts/st_set_page_config.py
index e7c992df9681..c08122f76c9a 100644
--- a/e2e/scripts/st_set_page_config.py
+++ b/e2e/scripts/st_set_page_config.py
@@ -22,3 +22,41 @@
 )
 st.sidebar.button("Sidebar!")
 st.markdown("Main!")
+
+
+def show_balloons():
+    st.balloons()
+
+
+st.button("Balloons", on_click=show_balloons)
+
+
+def double_set_page_config():
+    st.set_page_config(
+        page_title="Change 1",
+        page_icon=":shark:",
+        layout="wide",
+        initial_sidebar_state="collapsed",
+    )
+
+    st.set_page_config(
+        page_title="Change 2",
+        page_icon=":shark:",
+        layout="wide",
+        initial_sidebar_state="collapsed",
+    )
+
+
+st.button("Double Set Page Config", on_click=double_set_page_config)
+
+
+def single_set_page_config():
+    st.set_page_config(
+        page_title="Change 3",
+        page_icon=":shark:",
+        layout="wide",
+        initial_sidebar_state="collapsed",
+    )
+
+
+st.button("Single Set Page Config", on_click=single_set_page_config)
diff --git a/e2e/specs/st_set_page_config.spec.js b/e2e/specs/st_set_page_config.spec.js
index 0d1e775bccbe..f948ab4b7571 100644
--- a/e2e/specs/st_set_page_config.spec.js
+++ b/e2e/specs/st_set_page_config.spec.js
@@ -51,4 +51,47 @@ describe("st.set_page_config", () => {
       "wide-mode"
     );
   });
+
+  describe("double-setting set_page_config", () => {
+    beforeEach(() => {
+      // Rerun the script to ensure a fresh slate.
+      // This is done by typing r on the page
+      cy.get("body").type("r");
+      // Ensure the rerun completes
+      cy.wait(1000);
+    });
+
+    it("should not display an error when st command is used in a callback", () => {
+      cy.get(".stButton button")
+        .eq(1)
+        .click();
+
+      cy.get(".stException").should("not.exist");
+      cy.title().should("eq", "Heya, world?");
+    });
+
+    it("Should display an error when st.set_page_config is called multiple times in a callback", () => {
+      cy.get(".stButton button")
+        .eq(2)
+        .click();
+
+      cy.get(".stException")
+        .contains("set_page_config() can only be called once per app")
+        .should("exist");
+      // Ensure that the first set_page_config worked
+      cy.title().should("eq", "Change 1");
+    });
+
+    it("Should display an error when st.set_page_config is called multiple times in a callback", () => {
+      cy.get(".stButton button")
+        .eq(3)
+        .click();
+
+      cy.get(".stException")
+        .contains("set_page_config() can only be called once per app")
+        .should("exist");
+      // Ensure that the first set_page_config worked
+      cy.title().should("eq", "Change 3");
+    });
+  });
 });
diff --git a/frontend/cypress/snapshots/linux/2x/st_set_page_config.spec.js/wide-mode-dark.snap.png b/frontend/cypress/snapshots/linux/2x/st_set_page_config.spec.js/wide-mode-dark.snap.png
index e8a72c1db00f..8bfaff6eddd6 100644
Binary files a/frontend/cypress/snapshots/linux/2x/st_set_page_config.spec.js/wide-mode-dark.snap.png and b/frontend/cypress/snapshots/linux/2x/st_set_page_config.spec.js/wide-mode-dark.snap.png differ
diff --git a/frontend/cypress/snapshots/linux/2x/st_set_page_config.spec.js/wide-mode.snap.png b/frontend/cypress/snapshots/linux/2x/st_set_page_config.spec.js/wide-mode.snap.png
index 12267b69a0ee..402e75a7904c 100644
Binary files a/frontend/cypress/snapshots/linux/2x/st_set_page_config.spec.js/wide-mode.snap.png and b/frontend/cypress/snapshots/linux/2x/st_set_page_config.spec.js/wide-mode.snap.png differ
diff --git a/lib/streamlit/report_thread.py b/lib/streamlit/report_thread.py
index 1f3e65d86b48..66d679c6c3b8 100644
--- a/lib/streamlit/report_thread.py
+++ b/lib/streamlit/report_thread.py
@@ -73,6 +73,7 @@ def __init__(
         self.uploaded_file_mgr = uploaded_file_mgr
         # set_page_config is allowed at most once, as the very first st.command
         self._set_page_config_allowed = True
+        self._has_script_started = False
         # Stack of DGs used for the with block. The current one is at the end.
         self.dg_stack: List["streamlit.delta_generator.DeltaGenerator"] = []
 
@@ -86,6 +87,10 @@ def reset(self, query_string: str = "") -> None:
         self.query_string = query_string
         # Permit set_page_config when the ReportContext is reused on a rerun
         self._set_page_config_allowed = True
+        self._has_script_started = False
+
+    def on_script_start(self) -> None:
+        self._has_script_started = True
 
     def enqueue(self, msg: ForwardMsg) -> None:
         if msg.HasField("page_config_changed") and not self._set_page_config_allowed:
@@ -96,7 +101,12 @@ def enqueue(self, msg: ForwardMsg) -> None:
                 + "(https://docs.streamlit.io/en/stable/api.html#streamlit.set_page_config)."
             )
 
-        if msg.HasField("delta") or msg.HasField("page_config_changed"):
+        # We want to disallow set_page config if one of the following occurs:
+        # - set_page_config was called on this message
+        # - The script has already started and a different st call occurs (a delta)
+        if msg.HasField("page_config_changed") or (
+            msg.HasField("delta") and self._has_script_started
+        ):
             self._set_page_config_allowed = False
 
         self._enqueue(msg)
diff --git a/lib/streamlit/script_runner.py b/lib/streamlit/script_runner.py
index 11b4409c6ce6..5a0a8c3f4a33 100644
--- a/lib/streamlit/script_runner.py
+++ b/lib/streamlit/script_runner.py
@@ -346,6 +346,7 @@ def _run_script(self, rerun_data):
 
                     self._session_state.call_callbacks()
 
+                ctx.on_script_start()
                 exec(code, module.__dict__)
 
         except RerunException as e:
diff --git a/lib/tests/streamlit/report_context_test.py b/lib/tests/streamlit/report_context_test.py
index 840a7a7a6fbe..43b112b1b57d 100644
--- a/lib/tests/streamlit/report_context_test.py
+++ b/lib/tests/streamlit/report_context_test.py
@@ -42,7 +42,8 @@ def test_set_page_config_immutable(self):
             ctx.enqueue(msg)
 
     def test_set_page_config_first(self):
-        """st.set_page_config must be called before other st commands"""
+        """st.set_page_config must be called before other st commands
+        when the script has been marked as started"""
 
         fake_enqueue = lambda msg: None
         ctx = ReportContext(
@@ -53,6 +54,8 @@ def test_set_page_config_first(self):
             UploadedFileManager(),
         )
 
+        ctx.on_script_start()
+
         markdown_msg = ForwardMsg()
         markdown_msg.delta.new_element.markdown.body = "foo"
 
@@ -63,6 +66,30 @@ def test_set_page_config_first(self):
         with self.assertRaises(StreamlitAPIException):
             ctx.enqueue(msg)
 
+    def test_disallow_set_page_config_twice(self):
+        """st.set_page_config must be called before other st commands
+        when the script has been marked as started"""
+
+        fake_enqueue = lambda msg: None
+        ctx = ReportContext(
+            "TestSessionID",
+            fake_enqueue,
+            "",
+            SessionState(),
+            UploadedFileManager(),
+        )
+
+        ctx.on_script_start()
+
+        msg = ForwardMsg()
+        msg.page_config_changed.title = "foo"
+        ctx.enqueue(msg)
+
+        with self.assertRaises(StreamlitAPIException):
+            same_msg = ForwardMsg()
+            same_msg.page_config_changed.title = "bar"
+            ctx.enqueue(same_msg)
+
     def test_set_page_config_reset(self):
         """st.set_page_config should be allowed after a rerun"""
 
@@ -75,12 +102,15 @@ def test_set_page_config_reset(self):
             UploadedFileManager(),
         )
 
+        ctx.on_script_start()
+
         msg = ForwardMsg()
         msg.page_config_changed.title = "foo"
 
         ctx.enqueue(msg)
         ctx.reset()
         try:
+            ctx.on_script_start()
             ctx.enqueue(msg)
         except StreamlitAPIException:
             self.fail("set_page_config should have succeeded after reset!")


Code review comments:
diff_hunk: @@ -63,6 +66,30 @@ def test_set_page_config_first(self):
         with self.assertRaises(StreamlitAPIException):
             ctx.enqueue(msg)
 
+    def test_disallow_set_page_config_twice(self):
+        """st.set_page_config must be called before other st commands
path: lib/tests/streamlit/report_context_test.py
text: @user1:
nit: comment seems to have been copied from the test above and doesn't quite apply here