# Copyright 2018-2022 Streamlit Inc. # # 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. """Allows us to create and absorb changes (aka Deltas) to elements.""" from typing import Optional, Iterable import streamlit as st from streamlit import cursor, caching from streamlit import legacy_caching from streamlit import type_util from streamlit import util from streamlit.cursor import Cursor from streamlit.scriptrunner import get_script_run_ctx from streamlit.errors import StreamlitAPIException from streamlit.errors import NoSessionContext from streamlit.proto import Block_pb2 from streamlit.proto import ForwardMsg_pb2 from streamlit.proto.RootContainer_pb2 import RootContainer from streamlit.logger import get_logger from streamlit.elements.balloons import BalloonsMixin from streamlit.elements.button import ButtonMixin from streamlit.elements.markdown import MarkdownMixin from streamlit.elements.text import TextMixin from streamlit.elements.alert import AlertMixin from streamlit.elements.json import JsonMixin from streamlit.elements.doc_string import HelpMixin from streamlit.elements.exception import ExceptionMixin from streamlit.elements.bokeh_chart import BokehMixin from streamlit.elements.graphviz_chart import GraphvizMixin from streamlit.elements.plotly_chart import PlotlyMixin from streamlit.elements.deck_gl_json_chart import PydeckMixin from streamlit.elements.map import MapMixin from streamlit.elements.iframe import IframeMixin from streamlit.elements.media import MediaMixin from streamlit.elements.checkbox import CheckboxMixin from streamlit.elements.multiselect import MultiSelectMixin from streamlit.elements.metric import MetricMixin from streamlit.elements.radio import RadioMixin from streamlit.elements.selectbox import SelectboxMixin from streamlit.elements.text_widgets import TextWidgetsMixin from streamlit.elements.time_widgets import TimeWidgetsMixin from streamlit.elements.progress import ProgressMixin from streamlit.elements.empty import EmptyMixin from streamlit.elements.number_input import NumberInputMixin from streamlit.elements.camera_input import CameraInputMixin from streamlit.elements.color_picker import ColorPickerMixin from streamlit.elements.file_uploader import FileUploaderMixin from streamlit.elements.select_slider import SelectSliderMixin from streamlit.elements.slider import SliderMixin from streamlit.elements.snow import SnowMixin from streamlit.elements.image import ImageMixin from streamlit.elements.pyplot import PyplotMixin from streamlit.elements.write import WriteMixin from streamlit.elements.layouts import LayoutsMixin from streamlit.elements.form import FormMixin, FormData, current_form_id from streamlit.state import NoValue # DataFrame elements come in two flavors: "Legacy" and "Arrow". # We select between them with the DataFrameElementSelectorMixin. from streamlit.elements.arrow import ArrowMixin from streamlit.elements.arrow_altair import ArrowAltairMixin from streamlit.elements.arrow_vega_lite import ArrowVegaLiteMixin from streamlit.elements.legacy_data_frame import LegacyDataFrameMixin from streamlit.elements.legacy_altair import LegacyAltairMixin from streamlit.elements.legacy_vega_lite import LegacyVegaLiteMixin from streamlit.elements.dataframe_selector import DataFrameSelectorMixin LOGGER = get_logger(__name__) # Save the type built-in for when we override the name "type". _type = type MAX_DELTA_BYTES = 14 * 1024 * 1024 # 14MB # List of Streamlit commands that perform a Pandas "melt" operation on # input dataframes. DELTA_TYPES_THAT_MELT_DATAFRAMES = ("line_chart", "area_chart", "bar_chart") ARROW_DELTA_TYPES_THAT_MELT_DATAFRAMES = ( "arrow_line_chart", "arrow_area_chart", "arrow_bar_chart", ) class DeltaGenerator( AlertMixin, BalloonsMixin, BokehMixin, ButtonMixin, CameraInputMixin, CheckboxMixin, ColorPickerMixin, EmptyMixin, ExceptionMixin, FileUploaderMixin, FormMixin, GraphvizMixin, HelpMixin, IframeMixin, ImageMixin, LayoutsMixin, MarkdownMixin, MapMixin, MediaMixin, MetricMixin, MultiSelectMixin, NumberInputMixin, PlotlyMixin, ProgressMixin, PydeckMixin, PyplotMixin, RadioMixin, SelectboxMixin, SelectSliderMixin, SliderMixin, SnowMixin, JsonMixin, TextMixin, TextWidgetsMixin, TimeWidgetsMixin, WriteMixin, ArrowMixin, ArrowAltairMixin, ArrowVegaLiteMixin, LegacyDataFrameMixin, LegacyAltairMixin, LegacyVegaLiteMixin, DataFrameSelectorMixin, ): """Creator of Delta protobuf messages. Parameters ---------- root_container: BlockPath_pb2.BlockPath.ContainerValue or None The root container for this DeltaGenerator. If None, this is a null DeltaGenerator which doesn't print to the app at all (useful for testing). cursor: cursor.Cursor or None This is either: - None: if this is the running DeltaGenerator for a top-level container (MAIN or SIDEBAR) - RunningCursor: if this is the running DeltaGenerator for a non-top-level container (created with dg.container()) - LockedCursor: if this is a locked DeltaGenerator returned by some other DeltaGenerator method. E.g. the dg returned in dg = st.text("foo"). parent: DeltaGenerator To support the `with dg` notation, DGs are arranged as a tree. Each DG remembers its own parent, and the root of the tree is the main DG. block_type: None or "vertical" or "horizontal" or "column" or "expandable" If this is a block DG, we track its type to prevent nested columns/expanders """ # The pydoc below is for user consumption, so it doesn't talk about # DeltaGenerator constructor parameters (which users should never use). For # those, see above. def __init__( self, root_container: Optional[int] = RootContainer.MAIN, cursor: Optional[Cursor] = None, parent: Optional["DeltaGenerator"] = None, block_type: Optional[str] = None, ): """Inserts or updates elements in Streamlit apps. As a user, you should never initialize this object by hand. Instead, DeltaGenerator objects are initialized for you in two places: 1) When you call `dg = st.foo()` for some method "foo", sometimes `dg` is a DeltaGenerator object. You can call methods on the `dg` object to update the element `foo` that appears in the Streamlit app. 2) This is an internal detail, but `st.sidebar` itself is a DeltaGenerator. That's why you can call `st.sidebar.foo()` to place an element `foo` inside the sidebar. """ # Sanity check our Container + Cursor, to ensure that our Cursor # is using the same Container that we are. if ( root_container is not None and cursor is not None and root_container != cursor.root_container ): raise RuntimeError( "DeltaGenerator root_container and cursor.root_container must be the same" ) # Whether this DeltaGenerator is nested in the main area or sidebar. # No relation to `st.container()`. self._root_container = root_container # NOTE: You should never use this directly! Instead, use self._cursor, # which is a computed property that fetches the right cursor. self._provided_cursor = cursor self._parent = parent self._block_type = block_type # If this an `st.form` block, this will get filled in. self._form_data: Optional[FormData] = None # Change the module of all mixin'ed functions to be st.delta_generator, # instead of the original module (e.g. st.elements.markdown) for mixin in self.__class__.__bases__: for (name, func) in mixin.__dict__.items(): if callable(func): func.__module__ = self.__module__ def __repr__(self) -> str: return util.repr_(self) def __enter__(self): # with block started ctx = get_script_run_ctx() if ctx: ctx.dg_stack.append(self) def __exit__(self, type, value, traceback): # with block ended ctx = get_script_run_ctx() if ctx is not None: ctx.dg_stack.pop() # Re-raise any exceptions return False @property def _active_dg(self) -> "DeltaGenerator": """Return the DeltaGenerator that's currently 'active'. If we are the main DeltaGenerator, and are inside a `with` block that creates a container, our active_dg is that container. Otherwise, our active_dg is self. """ if self == self._main_dg: # We're being invoked via an `st.foo` pattern - use the current # `with` dg (aka the top of the stack). ctx = get_script_run_ctx() if ctx and len(ctx.dg_stack) > 0: return ctx.dg_stack[-1] # We're being invoked via an `st.sidebar.foo` pattern - ignore the # current `with` dg. return self @property def _main_dg(self) -> "DeltaGenerator": """Return this DeltaGenerator's root - that is, the top-level ancestor DeltaGenerator that we belong to (this generally means the st._main DeltaGenerator). """ return self._parent._main_dg if self._parent else self def __getattr__(self, name): import streamlit as st streamlit_methods = [ method_name for method_name in dir(st) if callable(getattr(st, method_name)) ] def wrapper(*args, **kwargs): if name in streamlit_methods: if self._root_container == RootContainer.SIDEBAR: message = ( "Method `%(name)s()` does not exist for " "`st.sidebar`. Did you mean `st.%(name)s()`?" % {"name": name} ) else: message = ( "Method `%(name)s()` does not exist for " "`DeltaGenerator` objects. Did you mean " "`st.%(name)s()`?" % {"name": name} ) else: message = "`%(name)s()` is not a valid Streamlit command." % { "name": name } raise StreamlitAPIException(message) return wrapper @property def _parent_block_types(self) -> Iterable[str]: """Iterate all the block types used by this DeltaGenerator and all its ancestor DeltaGenerators. """ current_dg: Optional[DeltaGenerator] = self while current_dg is not None: if current_dg._block_type is not None: yield current_dg._block_type current_dg = current_dg._parent @property def _cursor(self) -> Optional[Cursor]: """Return our Cursor. This will be None if we're not running in a ScriptThread - e.g., if we're running a "bare" script outside of Streamlit. """ if self._provided_cursor is None: return cursor.get_container_cursor(self._root_container) else: return self._provided_cursor @property def _is_top_level(self) -> bool: return self._provided_cursor is None def _get_delta_path_str(self) -> str: """Returns the element's delta path as a string like "[0, 2, 3, 1]". This uniquely identifies the element's position in the front-end, which allows (among other potential uses) the InMemoryFileManager to maintain session-specific maps of InMemoryFile objects placed with their "coordinates". This way, users can (say) use st.image with a stream of different images, and Streamlit will expire the older images and replace them in place. """ # Operate on the active DeltaGenerator, in case we're in a `with` block. dg = self._active_dg return str(dg._cursor.delta_path) if dg._cursor is not None else "[]" def _enqueue( self, delta_type, element_proto, return_value=None, last_index=None, element_width=None, element_height=None, ): """Create NewElement delta, fill it, and enqueue it. Parameters ---------- delta_type: string The name of the streamlit method being called element_proto: proto The actual proto in the NewElement type e.g. Alert/Button/Slider return_value: any or None The value to return to the calling script (for widgets) element_width : int or None Desired width for the element element_height : int or None Desired height for the element Returns ------- DeltaGenerator or any If this element is NOT an interactive widget, return a DeltaGenerator that can be used to modify the newly-created element. Otherwise, if the element IS a widget, return the `return_value` parameter. """ # Operate on the active DeltaGenerator, in case we're in a `with` block. dg = self._active_dg # Warn if we're called from within a legacy @st.cache function legacy_caching.maybe_show_cached_st_function_warning(dg, delta_type) # Warn if we're called from within @st.memo or @st.singleton caching.maybe_show_cached_st_function_warning(dg, delta_type) # Warn if an element is being changed but the user isn't running the streamlit server. st._maybe_print_use_warning() # Some elements have a method.__name__ != delta_type in proto. # This really matters for line_chart, bar_chart & area_chart, # since add_rows() relies on method.__name__ == delta_type # TODO: Fix for all elements (or the cache warning above will be wrong) proto_type = delta_type if proto_type in DELTA_TYPES_THAT_MELT_DATAFRAMES: proto_type = "vega_lite_chart" # Mirror the logic for arrow_ elements. if proto_type in ARROW_DELTA_TYPES_THAT_MELT_DATAFRAMES: proto_type = "arrow_vega_lite_chart" # Copy the marshalled proto into the overall msg proto msg = ForwardMsg_pb2.ForwardMsg() msg_el_proto = getattr(msg.delta.new_element, proto_type) msg_el_proto.CopyFrom(element_proto) # Only enqueue message and fill in metadata if there's a container. msg_was_enqueued = False if dg._root_container is not None and dg._cursor is not None: msg.metadata.delta_path[:] = dg._cursor.delta_path if element_width is not None: msg.metadata.element_dimension_spec.width = element_width if element_height is not None: msg.metadata.element_dimension_spec.height = element_height _enqueue_message(msg) msg_was_enqueued = True if msg_was_enqueued: # Get a DeltaGenerator that is locked to the current element # position. new_cursor = ( dg._cursor.get_locked_cursor( delta_type=delta_type, last_index=last_index ) if dg._cursor is not None else None ) output_dg = DeltaGenerator( root_container=dg._root_container, cursor=new_cursor, parent=dg, ) else: # If the message was not enqueued, just return self since it's a # no-op from the point of view of the app. output_dg = dg return _value_or_dg(return_value, output_dg) def _block(self, block_proto=Block_pb2.Block()) -> "DeltaGenerator": # Operate on the active DeltaGenerator, in case we're in a `with` block. dg = self._active_dg # Prevent nested columns & expanders by checking all parents. block_type = block_proto.WhichOneof("type") # Convert the generator to a list, so we can use it multiple times. parent_block_types = frozenset(dg._parent_block_types) if block_type == "column" and block_type in parent_block_types: raise StreamlitAPIException( "Columns may not be nested inside other columns." ) if block_type == "expandable" and block_type in parent_block_types: raise StreamlitAPIException( "Expanders may not be nested inside other expanders." ) if dg._root_container is None or dg._cursor is None: return dg msg = ForwardMsg_pb2.ForwardMsg() msg.metadata.delta_path[:] = dg._cursor.delta_path msg.delta.add_block.CopyFrom(block_proto) # Normally we'd return a new DeltaGenerator that uses the locked cursor # below. But in this case we want to return a DeltaGenerator that uses # a brand new cursor for this new block we're creating. block_cursor = cursor.RunningCursor( root_container=dg._root_container, parent_path=dg._cursor.parent_path + (dg._cursor.index,), ) block_dg = DeltaGenerator( root_container=dg._root_container, cursor=block_cursor, parent=dg, block_type=block_type, ) # Blocks inherit their parent form ids. # NOTE: Container form ids aren't set in proto. block_dg._form_data = FormData(current_form_id(dg)) # Must be called to increment this cursor's index. dg._cursor.get_locked_cursor(last_index=None) _enqueue_message(msg) return block_dg def _legacy_add_rows(self, data=None, **kwargs): """Concatenate a dataframe to the bottom of the current one. Parameters ---------- data : pandas.DataFrame, pandas.Styler, numpy.ndarray, Iterable, dict, or None Table to concat. Optional. **kwargs : pandas.DataFrame, numpy.ndarray, Iterable, dict, or None The named dataset to concat. Optional. You can only pass in 1 dataset (including the one in the data parameter). Example ------- >>> df1 = pd.DataFrame( ... np.random.randn(50, 20), ... columns=('col %d' % i for i in range(20))) ... >>> my_table = st._legacy_table(df1) >>> >>> df2 = pd.DataFrame( ... np.random.randn(50, 20), ... columns=('col %d' % i for i in range(20))) ... >>> my_table._legacy_add_rows(df2) >>> # Now the table shown in the Streamlit app contains the data for >>> # df1 followed by the data for df2. You can do the same thing with plots. For example, if you want to add more data to a line chart: >>> # Assuming df1 and df2 from the example above still exist... >>> my_chart = st._legacy_line_chart(df1) >>> my_chart._legacy_add_rows(df2) >>> # Now the chart shown in the Streamlit app contains the data for >>> # df1 followed by the data for df2. And for plots whose datasets are named, you can pass the data with a keyword argument where the key is the name: >>> my_chart = st._legacy_vega_lite_chart({ ... 'mark': 'line', ... 'encoding': {'x': 'a', 'y': 'b'}, ... 'datasets': { ... 'some_fancy_name': df1, # <-- named dataset ... }, ... 'data': {'name': 'some_fancy_name'}, ... }), >>> my_chart._legacy_add_rows(some_fancy_name=df2) # <-- name used as keyword """ if self._root_container is None or self._cursor is None: return self if not self._cursor.is_locked: raise StreamlitAPIException("Only existing elements can `add_rows`.") # Accept syntax st._legacy_add_rows(df). if data is not None and len(kwargs) == 0: name = "" # Accept syntax st._legacy_add_rows(foo=df). elif len(kwargs) == 1: name, data = kwargs.popitem() # Raise error otherwise. else: raise StreamlitAPIException( "Wrong number of arguments to add_rows()." "Command requires exactly one dataset" ) # When doing _legacy_add_rows on an element that does not already have data # (for example, st._legacy_ine_chart() without any args), call the original # st._legacy_foo() element with new data instead of doing an _legacy_add_rows(). if ( self._cursor.props["delta_type"] in DELTA_TYPES_THAT_MELT_DATAFRAMES and self._cursor.props["last_index"] is None ): # IMPORTANT: This assumes delta types and st method names always # match! # delta_type doesn't have any prefix, but st_method_name starts with "_legacy_". st_method_name = "_legacy_" + self._cursor.props["delta_type"] st_method = getattr(self, st_method_name) st_method(data, **kwargs) return data, self._cursor.props["last_index"] = _maybe_melt_data_for_add_rows( data, self._cursor.props["delta_type"], self._cursor.props["last_index"] ) msg = ForwardMsg_pb2.ForwardMsg() msg.metadata.delta_path[:] = self._cursor.delta_path import streamlit.elements.legacy_data_frame as data_frame data_frame.marshall_data_frame(data, msg.delta.add_rows.data) if name: msg.delta.add_rows.name = name msg.delta.add_rows.has_name = True _enqueue_message(msg) return self def _arrow_add_rows(self, data=None, **kwargs): """Concatenate a dataframe to the bottom of the current one. Parameters ---------- data : pandas.DataFrame, pandas.Styler, numpy.ndarray, Iterable, dict, or None Table to concat. Optional. **kwargs : pandas.DataFrame, numpy.ndarray, Iterable, dict, or None The named dataset to concat. Optional. You can only pass in 1 dataset (including the one in the data parameter). Example ------- >>> df1 = pd.DataFrame( ... np.random.randn(50, 20), ... columns=('col %d' % i for i in range(20))) ... >>> my_table = st._arrow_table(df1) >>> >>> df2 = pd.DataFrame( ... np.random.randn(50, 20), ... columns=('col %d' % i for i in range(20))) ... >>> my_table._arrow_add_rows(df2) >>> # Now the table shown in the Streamlit app contains the data for >>> # df1 followed by the data for df2. You can do the same thing with plots. For example, if you want to add more data to a line chart: >>> # Assuming df1 and df2 from the example above still exist... >>> my_chart = st._arrow_line_chart(df1) >>> my_chart._arrow_add_rows(df2) >>> # Now the chart shown in the Streamlit app contains the data for >>> # df1 followed by the data for df2. And for plots whose datasets are named, you can pass the data with a keyword argument where the key is the name: >>> my_chart = st._arrow_vega_lite_chart({ ... 'mark': 'line', ... 'encoding': {'x': 'a', 'y': 'b'}, ... 'datasets': { ... 'some_fancy_name': df1, # <-- named dataset ... }, ... 'data': {'name': 'some_fancy_name'}, ... }), >>> my_chart._arrow_add_rows(some_fancy_name=df2) # <-- name used as keyword """ if self._root_container is None or self._cursor is None: return self if not self._cursor.is_locked: raise StreamlitAPIException("Only existing elements can `add_rows`.") # Accept syntax st._arrow_add_rows(df). if data is not None and len(kwargs) == 0: name = "" # Accept syntax st._arrow_add_rows(foo=df). elif len(kwargs) == 1: name, data = kwargs.popitem() # Raise error otherwise. else: raise StreamlitAPIException( "Wrong number of arguments to add_rows()." "Command requires exactly one dataset" ) # When doing _arrow_add_rows on an element that does not already have data # (for example, st._arrow_line_chart() without any args), call the original # st._arrow_foo() element with new data instead of doing a _arrow_add_rows(). if ( self._cursor.props["delta_type"] in ARROW_DELTA_TYPES_THAT_MELT_DATAFRAMES and self._cursor.props["last_index"] is None ): # IMPORTANT: This assumes delta types and st method names always # match! # delta_type starts with "arrow_", but st_method_name starts with "_arrow_". st_method_name = "_" + self._cursor.props["delta_type"] st_method = getattr(self, st_method_name) st_method(data, **kwargs) return data, self._cursor.props["last_index"] = _maybe_melt_data_for_add_rows( data, self._cursor.props["delta_type"], self._cursor.props["last_index"] ) msg = ForwardMsg_pb2.ForwardMsg() msg.metadata.delta_path[:] = self._cursor.delta_path import streamlit.elements.arrow as arrow_proto default_uuid = str(hash(self._get_delta_path_str())) arrow_proto.marshall(msg.delta.arrow_add_rows.data, data, default_uuid) if name: msg.delta.arrow_add_rows.name = name msg.delta.arrow_add_rows.has_name = True _enqueue_message(msg) return self def _maybe_melt_data_for_add_rows(data, delta_type, last_index): import pandas as pd # For some delta types we have to reshape the data structure # otherwise the input data and the actual data used # by vega_lite will be different and it will throw an error. if ( delta_type in DELTA_TYPES_THAT_MELT_DATAFRAMES or delta_type in ARROW_DELTA_TYPES_THAT_MELT_DATAFRAMES ): if not isinstance(data, pd.DataFrame): data = type_util.convert_anything_to_df(data) if type(data.index) is pd.RangeIndex: old_step = _get_pandas_index_attr(data, "step") # We have to drop the predefined index data = data.reset_index(drop=True) old_stop = _get_pandas_index_attr(data, "stop") if old_step is None or old_stop is None: raise StreamlitAPIException( "'RangeIndex' object has no attribute 'step'" ) start = last_index + old_step stop = last_index + old_step + old_stop data.index = pd.RangeIndex(start=start, stop=stop, step=old_step) last_index = stop - 1 index_name = data.index.name if index_name is None: index_name = "index" data = pd.melt(data.reset_index(), id_vars=[index_name]) return data, last_index def _get_pandas_index_attr(data, attr): return getattr(data.index, attr, None) def _value_or_dg(value, dg): """Return either value, or None, or dg. This is needed because Widgets have meaningful return values. This is unlike other elements, which always return None. Then we internally replace that None with a DeltaGenerator instance. However, sometimes a widget may want to return None, and in this case it should not be replaced by a DeltaGenerator. So we have a special NoValue object that gets replaced by None. """ if value is NoValue: return None if value is None: return dg return value def _enqueue_message(msg): """Enqueues a ForwardMsg proto to send to the app.""" ctx = get_script_run_ctx() if ctx is None: raise NoSessionContext() ctx.enqueue(msg)