567 lines
17 KiB
Python
567 lines
17 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.
|
|
|
|
"""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
|
|
# "<foo blarg at 0x15ee6f9a0>".
|
|
return _shorten(value_str)
|
|
|
|
if is_mem_address_str(value_str):
|
|
# If value_str looks like "<foo blarg at 0x15ee6f9a0>" 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 []
|