# Copyright (c) Streamlit Inc. (2018-2022) Snowflake Inc. (2022) # # 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.""" import ast import contextlib import inspect import re import sys import types from typing import TYPE_CHECKING, Any, cast from typing_extensions import Final import streamlit from streamlit.logger import get_logger from streamlit.proto.DocString_pb2 import DocString as DocStringProto from streamlit.proto.DocString_pb2 import Member as MemberProto from streamlit.runtime.metrics_util import gather_metrics from streamlit.runtime.scriptrunner.script_runner import ( __file__ as SCRIPTRUNNER_FILENAME, ) from streamlit.runtime.secrets import Secrets from streamlit.string_util import is_mem_address_str if TYPE_CHECKING: from streamlit.delta_generator import DeltaGenerator LOGGER: Final = get_logger(__name__) CONFUSING_STREAMLIT_SIG_PREFIXES: Final = ("(element, ",) class HelpMixin: @gather_metrics("help") def help(self, obj: Any = streamlit) -> "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. 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() _marshall(doc_string_proto, obj) return self.dg._enqueue("doc_string", doc_string_proto) @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): # 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 name # Try to get the name of the object. # For example: # st.help(bar.Baz(123)) # # The name is Baz return getattr(obj, "__name__", None) def _get_module(obj): return getattr(obj, "__module__", None) def _get_signature(obj): if not inspect.isclass(obj) and not callable(obj): return None sig = "" # TODO: Can we replace below with this? # with contextlib.suppress(ValueError): # sig = str(inspect.signature(obj)) 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): 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. 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) ): doc_string = inspect.getdoc(obj_type) if doc_string: return doc_string.strip() return None def _get_variable_name(): """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): 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. if code.endswith(","): code = code[:-1] return code 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". # (The hasattr is there to support Python 3.7) elif hasattr(ast, "NamedExpr") and 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) in ( ast.Constant, # Python 3.7 support: ast.Num, ast.Str, ast.Bytes, ast.NameConstant, ast.Ellipsis, ): 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) # Python 3.7 and below have a bug where offset in some cases is off by one. # See https://github.com/python/cpython/commit/b619b097923155a7034c05c4018bf06af9f994d0 # By the way, Python 3.7 also displays this bug when arg_node is a generator # expression, but in that case there are further complications, so we're leaving it out # of here. See the unit test for this for more details. if sys.version_info < (3, 8) and type(arg_node) is ast.ListComp: start_offset -= 1 return first_line[start_offset:end_offset] _NEWLINES = re.compile(r"[\n\r]+") def _get_current_line_of_code_as_str(): 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(): 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, command_name): """Checks whether the AST in tree is a call for command_name.""" root_node = tree.body[0].value if not type(root_node) is 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): """Gets the argument node for the st command in tree (AST).""" root_node = tree.body[0].value if root_node.args: return root_node.args[0] return None def _get_type_as_str(obj): if inspect.isclass(obj): return "class" return str(type(obj).__name__) def _get_first_line(text): if not text: return "" left, _, _ = text.partition("\n") return left def _get_weight(value): if inspect.ismodule(value): return 3 if inspect.isclass(value): return 2 if callable(value): return 1 return 0 def _get_value(obj, var_name): 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: if module: obj_value = f"{module}.{name}{sig}" else: obj_value = 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): 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, length=300): s = s.strip() return s[:length] + "..." if len(s) > length else s def _is_computed_property(obj, attr_name): 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): members_for_sorting = [] for attr_name in dir(obj): if attr_name.startswith("_"): continue 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 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 []