# 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. """Allows us to create and absorb changes (aka Deltas) to elements.""" from __future__ import annotations import ast import contextlib import inspect import re import types from typing import TYPE_CHECKING, Any, Final, cast import streamlit from streamlit.elements.lib.layout_utils import LayoutConfig, validate_width from streamlit.proto.DocString_pb2 import DocString as DocStringProto from streamlit.proto.DocString_pb2 import Member as MemberProto from streamlit.runtime.caching.cache_utils import CachedFunc from streamlit.runtime.metrics_util import gather_metrics from streamlit.runtime.scriptrunner.script_runner import ( __file__ as SCRIPTRUNNER_FILENAME, # noqa: N812 ) from streamlit.runtime.secrets import Secrets from streamlit.string_util import is_mem_address_str if TYPE_CHECKING: from streamlit.delta_generator import DeltaGenerator from streamlit.elements.lib.layout_utils import WidthWithoutContent CONFUSING_STREAMLIT_SIG_PREFIXES: Final = ("(element, ",) class HelpMixin: @gather_metrics("help") def help( self, obj: Any = streamlit, *, width: WidthWithoutContent = "stretch" ) -> DeltaGenerator: """Display help and other information for a given object. Depending on the type of object that is passed in, this displays the object's name, type, value, signature, docstring, and member variables, methods — as well as the values/docstring of members and methods. Parameters ---------- obj : any The object whose information should be displayed. If left unspecified, this call will display help for Streamlit itself. width : "stretch" or int The width of the help element. This can be one of the following: - ``"stretch"`` (default): The width of the element matches the width of the parent container. - An integer specifying the width in pixels: The element has a fixed width. If the specified width is greater than the width of the parent container, the width of the element matches the width of the parent container. Example ------- Don't remember how to initialize a dataframe? Try this: >>> import streamlit as st >>> import pandas >>> >>> st.help(pandas.DataFrame) .. output:: https://doc-string.streamlit.app/ height: 700px Want to quickly check what data type is output by a certain function? Try: >>> import streamlit as st >>> >>> x = my_poorly_documented_function() >>> st.help(x) Want to quickly inspect an object? No sweat: >>> class Dog: >>> '''A typical dog.''' >>> >>> def __init__(self, breed, color): >>> self.breed = breed >>> self.color = color >>> >>> def bark(self): >>> return 'Woof!' >>> >>> >>> fido = Dog("poodle", "white") >>> >>> st.help(fido) .. output:: https://doc-string1.streamlit.app/ height: 300px And if you're using Magic, you can get help for functions, classes, and modules without even typing ``st.help``: >>> import streamlit as st >>> import pandas >>> >>> # Get help for Pandas read_csv: >>> pandas.read_csv >>> >>> # Get help for Streamlit itself: >>> st .. output:: https://doc-string2.streamlit.app/ height: 700px """ doc_string_proto = DocStringProto() validate_width(width, allow_content=False) layout_config = LayoutConfig(width=width) _marshall(doc_string_proto, obj) return self.dg._enqueue( "doc_string", doc_string_proto, layout_config=layout_config ) @property def dg(self) -> DeltaGenerator: """Get our DeltaGenerator.""" return cast("DeltaGenerator", self) def _marshall(doc_string_proto: DocStringProto, obj: Any) -> None: """Construct a DocString object. See DeltaGenerator.help for docs. """ var_name = _get_variable_name() if var_name is not None: doc_string_proto.name = var_name obj_type = _get_type_as_str(obj) doc_string_proto.type = obj_type obj_docs = _get_docstring(obj) if obj_docs is not None: doc_string_proto.doc_string = obj_docs obj_value = _get_value(obj, var_name) if obj_value is not None: doc_string_proto.value = obj_value doc_string_proto.members.extend(_get_members(obj)) def _get_name(obj: object) -> str | None: # Try to get the fully-qualified name of the object. # For example: st.help(bar.Baz(123)) # The name is bar.Baz name = getattr(obj, "__qualname__", None) if name: return cast("str", name) # Try to get the name of the object. # For example: st.help(bar.Baz(123)) # The name is Baz return cast("str | None", getattr(obj, "__name__", None)) def _get_module(obj: object) -> str | None: return getattr(obj, "__module__", None) def _get_signature(obj: object) -> str | None: if not inspect.isclass(obj) and not callable(obj): return None sig = "" try: sig = str(inspect.signature(obj)) except ValueError: sig = "(...)" except TypeError: return None is_delta_gen = False with contextlib.suppress(AttributeError): is_delta_gen = obj.__module__ == "streamlit.delta_generator" # Functions such as numpy.minimum don't have a __module__ attribute, # since we're only using it to check if its a DeltaGenerator, its ok # to continue if is_delta_gen: for prefix in CONFUSING_STREAMLIT_SIG_PREFIXES: if sig.startswith(prefix): sig = sig.replace(prefix, "(") break return sig def _get_docstring(obj: object) -> str | None: doc_string = inspect.getdoc(obj) # Sometimes an object has no docstring, but the object's type does. # If that's the case here, use the type's docstring. # For objects where type is "type" we do not print the docs (e.g. int). # We also do not print the docs for functions and methods if the docstring is empty. # We treat CachedFunc objects in the same way as functions. if doc_string is None: obj_type = type(obj) if ( obj_type is not type and obj_type is not types.ModuleType and not inspect.isfunction(obj) and not inspect.ismethod(obj) and obj_type is not CachedFunc ): doc_string = inspect.getdoc(obj_type) if doc_string: return doc_string.strip() return None def _get_variable_name() -> str | None: """Try to get the name of the variable in the current line, as set by the user. For example: foo = bar.Baz(123) st.help(foo) The name is "foo" """ code = _get_current_line_of_code_as_str() if code is None: return None return _get_variable_name_from_code_str(code) def _get_variable_name_from_code_str(code: str) -> str | None: tree = ast.parse(code) # Example: # # > tree = Module( # > body=[ # > Expr( # > value=Call( # > args=[ # > Name(id='the variable name') # > ], # > keywords=[ # > ??? # > ], # > ) # > ) # > ] # > ) # Check if this is an magic call (i.e. it's not st.help or st.write). # If that's the case, just clean it up and return it. if not _is_stcommand(tree, command_name="help") and not _is_stcommand( tree, command_name="write" ): # A common pattern is to add "," at the end of a magic command to make it print. # This removes that final ",", so it looks nicer. return code.removesuffix(",") arg_node = _get_stcommand_arg(tree) # If st.help() is called without an argument, return no variable name. if not arg_node: return None # If walrus, get name. # E.g. st.help(foo := 123) should give you "foo". if type(arg_node) is ast.NamedExpr: # This next "if" will always be true, but need to add this for the type-checking test to # pass. if type(arg_node.target) is ast.Name: return arg_node.target.id # If constant, there's no variable name. # E.g. st.help("foo") or st.help(123) should give you None. elif type(arg_node) is ast.Constant: return None # Otherwise, return whatever is inside st.help(<-- here -->) # But, if multiline, only return the first line. code_lines = code.split("\n") is_multiline = len(code_lines) > 1 start_offset = arg_node.col_offset if is_multiline: first_lineno = arg_node.lineno - 1 # Lines are 1-indexed! first_line = code_lines[first_lineno] end_offset = None else: first_line = code_lines[0] end_offset = getattr(arg_node, "end_col_offset", -1) return first_line[start_offset:end_offset] _NEWLINES = re.compile(r"[\n\r]+") def _get_current_line_of_code_as_str() -> str | None: scriptrunner_frame = _get_scriptrunner_frame() if scriptrunner_frame is None: # If there's no ScriptRunner frame, something weird is going on. This # can happen when the script is executed with `python myscript.py`. # Either way, let's bail out nicely just in case there's some valid # edge case where this is OK. return None code_context = scriptrunner_frame.code_context if not code_context: # Sometimes a frame has no code_context. This can happen inside certain exec() calls, for # example. If this happens, we can't determine the variable name. Just return. # For the background on why exec() doesn't produce code_context, see # https://stackoverflow.com/a/12072941 return None code_as_string = "".join(code_context) return re.sub(_NEWLINES, "", code_as_string.strip()) def _get_scriptrunner_frame() -> inspect.FrameInfo | None: prev_frame = None scriptrunner_frame = None # Look back in call stack to get the variable name passed into st.help(). # The frame *before* the ScriptRunner frame is the correct one. # IMPORTANT: This will change if we refactor the code. But hopefully our tests will catch the # issue and we'll fix it before it lands upstream! for frame in inspect.stack(): # Check if this is running inside a funny "exec()" block that won't provide the info we # need. If so, just quit. if frame.code_context is None: return None if frame.filename == SCRIPTRUNNER_FILENAME: scriptrunner_frame = prev_frame break prev_frame = frame return scriptrunner_frame def _is_stcommand(tree: Any, command_name: str) -> bool: """Checks whether the AST in tree is a call for command_name.""" root_node = tree.body[0].value if not isinstance(root_node, ast.Call): return False return ( # st call called without module. E.g. "help()" getattr(root_node.func, "id", None) == command_name or # st call called with module. E.g. "foo.help()" (where usually "foo" is "st") getattr(root_node.func, "attr", None) == command_name ) def _get_stcommand_arg(tree: ast.Module) -> ast.expr | None: """Gets the argument node for the st command in tree (AST).""" root_node = tree.body[0].value # type: ignore if root_node.args: return cast("ast.expr", root_node.args[0]) return None def _get_type_as_str(obj: object) -> str: if inspect.isclass(obj): return "class" return str(type(obj).__name__) def _get_first_line(text: str) -> str: if not text: return "" left, _, _ = text.partition("\n") return left def _get_weight(value: Any) -> int: if inspect.ismodule(value): return 3 if inspect.isclass(value): return 2 if callable(value): return 1 return 0 def _get_value(obj: object, var_name: str | None) -> str | None: obj_value = _get_human_readable_value(obj) if obj_value is not None: return obj_value # If there's no human-readable value, it's some complex object. # So let's provide other info about it. name = _get_name(obj) if name: name_obj = obj else: # If the object itself doesn't have a name, then it's probably an instance # of some class Foo. So let's show info about Foo in the value slot. name_obj = type(obj) name = _get_name(name_obj) module = _get_module(name_obj) sig = _get_signature(name_obj) or "" if name: obj_value = f"{module}.{name}{sig}" if module else f"{name}{sig}" if obj_value == var_name: # No need to repeat the same info. # For example: st.help(re) shouldn't show "re module re", just "re module". obj_value = None return obj_value def _get_human_readable_value(value: Any) -> str | None: if isinstance(value, Secrets): # Don't want to read secrets.toml because that will show a warning if there's no # secrets.toml file. return None if inspect.isclass(value) or inspect.ismodule(value) or callable(value): return None value_str = repr(value) if isinstance(value, str): # Special-case strings as human-readable because they're allowed to look like # "". return _shorten(value_str) if is_mem_address_str(value_str): # If value_str looks like "" it's not human readable. return None return _shorten(value_str) def _shorten(s: str, length: int = 300) -> str: s = s.strip() return s[:length] + "..." if len(s) > length else s def _is_computed_property(obj: object, attr_name: str) -> bool: obj_class = getattr(obj, "__class__", None) if not obj_class: return False # Go through superclasses in order of inheritance (mro) to see if any of them have an # attribute called attr_name. If so, check if it's a @property. for parent_class in inspect.getmro(obj_class): class_attr = getattr(parent_class, attr_name, None) if class_attr is None: continue # If is property, return it. if isinstance(class_attr, property) or inspect.isgetsetdescriptor(class_attr): return True return False def _get_members(obj: object) -> list[MemberProto]: members_for_sorting = [] for attr_name in dir(obj): if attr_name.startswith("_"): continue try: is_computed_value = _is_computed_property(obj, attr_name) if is_computed_value: parent_attr = getattr(obj.__class__, attr_name) member_type = "property" weight = 0 member_docs = _get_docstring(parent_attr) member_value = None else: attr_value = getattr(obj, attr_name) weight = _get_weight(attr_value) human_readable_value = _get_human_readable_value(attr_value) member_type = _get_type_as_str(attr_value) if human_readable_value is None: member_docs = _get_docstring(attr_value) member_value = None else: member_docs = None member_value = human_readable_value except AttributeError: # If there's an AttributeError, we can just skip it. # This can happen when members are exposed with `dir()` # but are conditionally unavailable. continue if member_type == "module": # Don't pollute the output with all imported modules. continue member = MemberProto() member.name = attr_name member.type = member_type if member_docs is not None: member.doc_string = _get_first_line(member_docs) if member_value is not None: member.value = member_value members_for_sorting.append((weight, member)) if members_for_sorting: sorted_members = sorted(members_for_sorting, key=lambda x: (x[0], x[1].name)) return [m for _, m in sorted_members] return []