429 lines
15 KiB
Python
429 lines
15 KiB
Python
# Copyright (c) Streamlit Inc. (2018-2022) Snowflake Inc. (2022-2025)
|
|
#
|
|
# 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.
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Mapping, Sequence
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING, Callable, Literal, Union
|
|
|
|
from typing_extensions import TypeAlias
|
|
|
|
from streamlit import config
|
|
from streamlit.errors import StreamlitAPIException
|
|
from streamlit.navigation.page import StreamlitPage
|
|
from streamlit.proto.ForwardMsg_pb2 import ForwardMsg
|
|
from streamlit.proto.Navigation_pb2 import Navigation as NavigationProto
|
|
from streamlit.runtime.metrics_util import gather_metrics
|
|
from streamlit.runtime.pages_manager import PagesManager
|
|
from streamlit.runtime.scriptrunner_utils.script_run_context import (
|
|
ScriptRunContext,
|
|
get_script_run_ctx,
|
|
)
|
|
from streamlit.string_util import is_emoji
|
|
|
|
if TYPE_CHECKING:
|
|
from streamlit.source_util import PageHash, PageInfo
|
|
|
|
SectionHeader: TypeAlias = str
|
|
PageType: TypeAlias = Union[str, Path, Callable[[], None], StreamlitPage]
|
|
|
|
|
|
def convert_to_streamlit_page(
|
|
page_input: PageType,
|
|
) -> StreamlitPage:
|
|
"""Convert various input types to StreamlitPage objects."""
|
|
if isinstance(page_input, StreamlitPage):
|
|
return page_input
|
|
|
|
if isinstance(page_input, str):
|
|
return StreamlitPage(page_input)
|
|
|
|
if isinstance(page_input, Path):
|
|
return StreamlitPage(page_input)
|
|
|
|
if callable(page_input):
|
|
# Convert function to StreamlitPage
|
|
return StreamlitPage(page_input)
|
|
|
|
raise StreamlitAPIException(
|
|
f"Invalid page type: {type(page_input)}. Must be either a string path, "
|
|
"a pathlib.Path, a callable function, or a st.Page object."
|
|
)
|
|
|
|
|
|
def pages_from_nav_sections(
|
|
nav_sections: dict[SectionHeader, list[StreamlitPage]],
|
|
) -> list[StreamlitPage]:
|
|
page_list = []
|
|
for pages in nav_sections.values():
|
|
page_list.extend(pages.copy())
|
|
|
|
return page_list
|
|
|
|
|
|
def send_page_not_found(ctx: ScriptRunContext) -> None:
|
|
msg = ForwardMsg()
|
|
msg.page_not_found.page_name = ""
|
|
ctx.enqueue(msg)
|
|
|
|
|
|
@gather_metrics("navigation")
|
|
def navigation(
|
|
pages: Sequence[PageType] | Mapping[SectionHeader, Sequence[PageType]],
|
|
*,
|
|
position: Literal["sidebar", "hidden", "top"] = "sidebar",
|
|
expanded: bool = False,
|
|
) -> StreamlitPage:
|
|
"""
|
|
Configure the available pages in a multipage app.
|
|
|
|
Call ``st.navigation`` in your entrypoint file to define the available
|
|
pages for your app. ``st.navigation`` returns the current page, which can
|
|
be executed using ``.run()`` method.
|
|
|
|
When using ``st.navigation``, your entrypoint file (the file passed to
|
|
``streamlit run``) acts like a router or frame of common elements around
|
|
each of your pages. Streamlit executes the entrypoint file with every app
|
|
rerun. To execute the current page, you must call the ``.run()`` method on
|
|
the ``StreamlitPage`` object returned by ``st.navigation``.
|
|
|
|
The set of available pages can be updated with each rerun for dynamic
|
|
navigation. By default, ``st.navigation`` displays the available pages in
|
|
the sidebar if there is more than one page. This behavior can be changed
|
|
using the ``position`` keyword argument.
|
|
|
|
As soon as any session of your app executes the ``st.navigation`` command,
|
|
your app will ignore the ``pages/`` directory (across all sessions).
|
|
|
|
Parameters
|
|
----------
|
|
pages : Sequence[page-like], Mapping[str, Sequence[page-like]]
|
|
The available pages for the app.
|
|
|
|
To create a navigation menu with no sections or page groupings,
|
|
``pages`` must be a list of page-like objects. Page-like objects are
|
|
anything that can be passed to ``st.Page`` or a ``StreamlitPage``
|
|
object returned by ``st.Page``.
|
|
|
|
To create labeled sections or page groupings within the navigation
|
|
menu, ``pages`` must be a dictionary. Each key is the label of a
|
|
section and each value is the list of page-like objects for
|
|
that section. If you use ``position="top"``, each grouping will be a
|
|
collapsible item in the navigation menu.
|
|
|
|
When you use a string or path as a page-like object, they are
|
|
internally passed to ``st.Page`` and converted to ``StreamlitPage``
|
|
objects. In this case, the page will have the default title, icon, and
|
|
path inferred from its path or filename. To customize these attributes
|
|
for your page, initialize your page with ``st.Page``.
|
|
|
|
position : "sidebar", "top", or "hidden"
|
|
The position of the navigation menu. If this is ``"sidebar"``
|
|
(default), the navigation widget appears at the top of the sidebar. If
|
|
this is ``"top"``, the navigation appears in the top header of the app.
|
|
If this is ``"hidden"``, the navigation widget is not displayed.
|
|
|
|
If there is only one page in ``pages``, the navigation will be hidden
|
|
for any value of ``position``.
|
|
|
|
expanded : bool
|
|
Whether the navigation menu should be expanded. If this is ``False``
|
|
(default), the navigation menu will be collapsed and will include a
|
|
button to view more options when there are too many pages to display.
|
|
If this is ``True``, the navigation menu will always be expanded; no
|
|
button to collapse the menu will be displayed.
|
|
|
|
If ``st.navigation`` changes from ``expanded=True`` to
|
|
``expanded=False`` on a rerun, the menu will stay expanded and a
|
|
collapse button will be displayed.
|
|
|
|
The parameter is only used when ``position="sidebar"``.
|
|
|
|
Returns
|
|
-------
|
|
StreamlitPage
|
|
The current page selected by the user. To run the page, you must use
|
|
the ``.run()`` method on it.
|
|
|
|
Examples
|
|
--------
|
|
The following examples show different possible entrypoint files, each named
|
|
``streamlit_app.py``. An entrypoint file is passed to ``streamlit run``. It
|
|
manages your app's navigation and serves as a router between pages.
|
|
|
|
**Example 1: Use a callable or Python file as a page**
|
|
|
|
You can declare pages from callables or file paths. If you pass callables
|
|
or paths to ``st.navigation`` as a page-like objects, they are internally
|
|
converted to ``StreamlitPage`` objects using ``st.Page``. In this case, the
|
|
page titles, icons, and paths are inferred from the file or callable names.
|
|
|
|
``page_1.py`` (in the same directory as your entrypoint file):
|
|
|
|
>>> import streamlit as st
|
|
>>>
|
|
>>> st.title("Page 1")
|
|
|
|
``streamlit_app.py``:
|
|
|
|
>>> import streamlit as st
|
|
>>>
|
|
>>> def page_2():
|
|
... st.title("Page 2")
|
|
>>>
|
|
>>> pg = st.navigation(["page_1.py", page_2])
|
|
>>> pg.run()
|
|
|
|
.. output::
|
|
https://doc-navigation-example-1.streamlit.app/
|
|
height: 200px
|
|
|
|
**Example 2: Group pages into sections and customize them with ``st.Page``**
|
|
|
|
You can use a dictionary to create sections within your navigation menu. In
|
|
the following example, each page is similar to Page 1 in Example 1, and all
|
|
pages are in the same directory. However, you can use Python files from
|
|
anywhere in your repository. ``st.Page`` is used to give each page a custom
|
|
title. For more information, see |st.Page|_.
|
|
|
|
Directory structure:
|
|
|
|
>>> your_repository/
|
|
>>> ├── create_account.py
|
|
>>> ├── learn.py
|
|
>>> ├── manage_account.py
|
|
>>> ├── streamlit_app.py
|
|
>>> └── trial.py
|
|
|
|
``streamlit_app.py``:
|
|
|
|
>>> import streamlit as st
|
|
>>>
|
|
>>> pages = {
|
|
... "Your account": [
|
|
... st.Page("create_account.py", title="Create your account"),
|
|
... st.Page("manage_account.py", title="Manage your account"),
|
|
... ],
|
|
... "Resources": [
|
|
... st.Page("learn.py", title="Learn about us"),
|
|
... st.Page("trial.py", title="Try it out"),
|
|
... ],
|
|
... }
|
|
>>>
|
|
>>> pg = st.navigation(pages)
|
|
>>> pg.run()
|
|
|
|
.. output::
|
|
https://doc-navigation-example-2.streamlit.app/
|
|
height: 300px
|
|
|
|
|
|
**Example 3: Use top navigation**
|
|
|
|
You can use the ``position`` parameter to place the navigation at the top
|
|
of the app. This is useful for apps with a lot of pages because it allows
|
|
you to create collapsible sections for each group of pages. The following
|
|
example uses the same directory structure as Example 2 and shows how to
|
|
create a top navigation menu.
|
|
|
|
``streamlit_app.py``:
|
|
|
|
>>> import streamlit as st
|
|
>>>
|
|
>>> pages = {
|
|
... "Your account": [
|
|
... st.Page("create_account.py", title="Create your account"),
|
|
... st.Page("manage_account.py", title="Manage your account"),
|
|
... ],
|
|
... "Resources": [
|
|
... st.Page("learn.py", title="Learn about us"),
|
|
... st.Page("trial.py", title="Try it out"),
|
|
... ],
|
|
... }
|
|
>>>
|
|
>>> pg = st.navigation(pages, position="top")
|
|
>>> pg.run()
|
|
|
|
.. output::
|
|
https://doc-navigation-top.streamlit.app/
|
|
height: 300px
|
|
|
|
**Example 4: Stateful widgets across multiple pages**
|
|
|
|
Call widget functions in your entrypoint file when you want a widget to be
|
|
stateful across pages. Assign keys to your common widgets and access their
|
|
values through Session State within your pages.
|
|
|
|
``streamlit_app.py``:
|
|
|
|
>>> import streamlit as st
|
|
>>>
|
|
>>> def page1():
|
|
>>> st.write(st.session_state.foo)
|
|
>>>
|
|
>>> def page2():
|
|
>>> st.write(st.session_state.bar)
|
|
>>>
|
|
>>> # Widgets shared by all the pages
|
|
>>> st.sidebar.selectbox("Foo", ["A", "B", "C"], key="foo")
|
|
>>> st.sidebar.checkbox("Bar", key="bar")
|
|
>>>
|
|
>>> pg = st.navigation([page1, page2])
|
|
>>> pg.run()
|
|
|
|
.. output::
|
|
https://doc-navigation-multipage-widgets.streamlit.app/
|
|
height: 350px
|
|
|
|
.. |st.Page| replace:: ``st.Page``
|
|
.. _st.Page: https://docs.streamlit.io/develop/api-reference/navigation/st.page
|
|
|
|
"""
|
|
# Validate position parameter
|
|
if not isinstance(position, str) or position not in ["sidebar", "hidden", "top"]:
|
|
raise StreamlitAPIException(
|
|
f'Invalid position "{position}". '
|
|
'The position parameter must be one of "sidebar", "hidden", or "top".'
|
|
)
|
|
|
|
# Disable the use of the pages feature (ie disregard v1 behavior of Multipage Apps)
|
|
PagesManager.uses_pages_directory = False
|
|
|
|
return _navigation(pages, position=position, expanded=expanded)
|
|
|
|
|
|
def _navigation(
|
|
pages: Sequence[PageType] | Mapping[SectionHeader, Sequence[PageType]],
|
|
*,
|
|
position: Literal["sidebar", "hidden", "top"],
|
|
expanded: bool,
|
|
) -> StreamlitPage:
|
|
if isinstance(pages, Sequence):
|
|
converted_pages = [convert_to_streamlit_page(p) for p in pages]
|
|
nav_sections = {"": converted_pages}
|
|
else:
|
|
nav_sections = {
|
|
section: [convert_to_streamlit_page(p) for p in section_pages]
|
|
for section, section_pages in pages.items()
|
|
}
|
|
page_list = pages_from_nav_sections(nav_sections)
|
|
|
|
if not page_list:
|
|
raise StreamlitAPIException(
|
|
"`st.navigation` must be called with at least one `st.Page`."
|
|
)
|
|
|
|
default_page = None
|
|
pagehash_to_pageinfo: dict[PageHash, PageInfo] = {}
|
|
|
|
# Get the default page.
|
|
for section_header in nav_sections:
|
|
for page in nav_sections[section_header]:
|
|
if page._default:
|
|
if default_page is not None:
|
|
raise StreamlitAPIException(
|
|
"Multiple Pages specified with `default=True`. "
|
|
"At most one Page can be set to default."
|
|
)
|
|
default_page = page
|
|
|
|
if default_page is None:
|
|
default_page = page_list[0]
|
|
default_page._default = True
|
|
|
|
ctx = get_script_run_ctx()
|
|
if not ctx:
|
|
# This should never run in Streamlit, but we want to make sure that
|
|
# the function always returns a page
|
|
default_page._can_be_called = True
|
|
return default_page
|
|
|
|
# Build the pagehash-to-pageinfo mapping.
|
|
for section_header in nav_sections:
|
|
for page in nav_sections[section_header]:
|
|
script_path = str(page._page) if isinstance(page._page, Path) else ""
|
|
|
|
script_hash = page._script_hash
|
|
if script_hash in pagehash_to_pageinfo:
|
|
# The page script hash is solely based on the url path
|
|
# So duplicate page script hashes are due to duplicate url paths
|
|
raise StreamlitAPIException(
|
|
f"Multiple Pages specified with URL pathname {page.url_path}. "
|
|
"URL pathnames must be unique. The url pathname may be "
|
|
"inferred from the filename, callable name, or title."
|
|
)
|
|
|
|
pagehash_to_pageinfo[script_hash] = {
|
|
"page_script_hash": script_hash,
|
|
"page_name": page.title,
|
|
"icon": page.icon,
|
|
"script_path": script_path,
|
|
"url_pathname": page.url_path,
|
|
}
|
|
|
|
msg = ForwardMsg()
|
|
# Handle position logic correctly
|
|
if position == "hidden":
|
|
msg.navigation.position = NavigationProto.Position.HIDDEN
|
|
elif position == "top":
|
|
msg.navigation.position = NavigationProto.Position.TOP
|
|
elif position == "sidebar":
|
|
# Only apply config override if position is sidebar
|
|
if config.get_option("client.showSidebarNavigation") is False:
|
|
msg.navigation.position = NavigationProto.Position.HIDDEN
|
|
else:
|
|
msg.navigation.position = NavigationProto.Position.SIDEBAR
|
|
|
|
msg.navigation.expanded = expanded
|
|
msg.navigation.sections[:] = nav_sections.keys()
|
|
for section_header in nav_sections:
|
|
for page in nav_sections[section_header]:
|
|
p = msg.navigation.app_pages.add()
|
|
p.page_script_hash = page._script_hash
|
|
p.page_name = page.title
|
|
p.icon = f"emoji:{page.icon}" if is_emoji(page.icon) else page.icon
|
|
p.is_default = page._default
|
|
p.section_header = section_header
|
|
p.url_pathname = page.url_path
|
|
|
|
# Inform our page manager about the set of pages we have
|
|
ctx.pages_manager.set_pages(pagehash_to_pageinfo)
|
|
found_page = ctx.pages_manager.get_page_script(
|
|
fallback_page_hash=default_page._script_hash
|
|
)
|
|
|
|
page_to_return = None
|
|
if found_page:
|
|
found_page_script_hash = found_page["page_script_hash"]
|
|
matching_pages = [
|
|
p for p in page_list if p._script_hash == found_page_script_hash
|
|
]
|
|
if len(matching_pages) > 0:
|
|
page_to_return = matching_pages[0]
|
|
|
|
if not page_to_return:
|
|
send_page_not_found(ctx)
|
|
page_to_return = default_page
|
|
|
|
# Ordain the page that can be called
|
|
page_to_return._can_be_called = True
|
|
msg.navigation.page_script_hash = page_to_return._script_hash
|
|
# Set the current page script hash to the page that is going to be executed
|
|
ctx.set_mpa_v2_page(page_to_return._script_hash)
|
|
|
|
# This will either navigation or yield if the page is not found
|
|
ctx.enqueue(msg)
|
|
|
|
return page_to_return
|