Using custom Python classes in your Streamlit app

如果您正在构建一个复杂的Streamlit应用程序或处理现有代码,您可能在脚本中定义了自定义的Python类。常见的例子包括以下内容:

  • 定义一个@dataclass来存储应用程序中的相关数据。
  • 定义一个Enum类来表示一组固定的选项或值。
  • 定义自定义接口以连接到st.connection未涵盖的外部服务或数据库。

由于Streamlit在每次用户交互后都会重新运行您的脚本,自定义类可能会在同一Streamlit会话中被多次重新定义。这可能会导致不期望的效果,特别是在类和实例比较时。继续阅读以了解这个常见的陷阱以及如何避免它。

我们首先介绍一些可以用于不同类型自定义类的通用模式,然后解释一些技术细节,说明为什么这些模式很重要。最后,我们将更详细地讨论使用Enum,并描述一个可以使它们更方便的配置选项。

这是推荐的通用解决方案。如果可能,将类定义移动到它们自己的模块文件中,并将它们导入到您的应用程序脚本中。只要您不编辑定义类的文件,Streamlit 就不会在每次重新运行时重新导入它。因此,如果类在外部文件中定义并导入到您的脚本中,该类在会话期间不会被重新定义。

示例:移动您的类定义

尝试运行以下Streamlit应用程序,其中MyClass在页面的脚本中定义。isinstance()将在第一次脚本运行时返回True,然后在每次重新运行后返回False

# app.py import streamlit as st # MyClass gets redefined every time app.py reruns class MyClass: def __init__(self, var1, var2): self.var1 = var1 self.var2 = var2 if "my_instance" not in st.session_state: st.session_state.my_instance = MyClass("foo", "bar") # Displays True on the first run then False on every rerun st.write(isinstance(st.session_state.my_instance, MyClass)) st.button("Rerun")

如果你将类定义从app.py移到另一个文件中,你可以使isinstance()始终返回True。考虑以下文件结构:

myproject/ ├── my_class.py └── app.py
# my_class.py class MyClass: def __init__(self, var1, var2): self.var1 = var1 self.var2 = var2
# app.py import streamlit as st from my_class import MyClass # MyClass doesn't get redefined with each rerun if "my_instance" not in st.session_state: st.session_state.my_instance = MyClass("foo", "bar") # Displays True on every rerun st.write(isinstance(st.session_state.my_instance, MyClass)) st.button("Rerun")

Streamlit 仅在检测到代码更改时重新加载导入模块中的代码。因此,如果您正在积极编辑定义类的文件,您可能需要停止并重新启动 Streamlit 服务器,以避免在会话中发生不希望的类重新定义。

对于存储数据的类(如dataclasses),你可能更感兴趣的是比较内部存储的值而不是类本身。如果你定义了一个自定义的__eq__方法,你可以强制在内部存储的值上进行比较。

示例:定义 __eq__

尝试运行以下Streamlit应用程序,并观察比较在第一次运行时如何为True,然后在每次重新运行后为False

import streamlit as st from dataclasses import dataclass @dataclass class MyDataclass: var1: int var2: float if "my_dataclass" not in st.session_state: st.session_state.my_dataclass = MyDataclass(1, 5.5) # Displays True on the first run the False on every rerun st.session_state.my_dataclass == MyDataclass(1, 5.5) st.button("Rerun")

由于每次重新运行都会重新定义MyDataclass,存储在Session State中的实例将不等于在后续脚本运行中定义的任何实例。您可以通过强制比较内部值来解决此问题,如下所示:

import streamlit as st from dataclasses import dataclass @dataclass class MyDataclass: var1: int var2: float def __eq__(self, other): # An instance of MyDataclass is equal to another object if the object # contains the same fields with the same values return (self.var1, self.var2) == (other.var1, other.var2) if "my_dataclass" not in st.session_state: st.session_state.my_dataclass = MyDataclass(1, 5.5) # Displays True on every rerun st.session_state.my_dataclass == MyDataclass(1, 5.5) st.button("Rerun")

默认的Python __eq__ 实现对于常规类或 @dataclass 依赖于类或类实例的内存ID。为了避免在Streamlit中出现问题,您的自定义 __eq__ 方法不应依赖于 selfothertype()

对于存储数据的类,另一个选择是为你的类定义序列化和反序列化方法,如to_strfrom_str。你可以使用这些方法将类实例数据存储在st.session_state中,而不是存储类实例本身。与模式2类似,这是一种强制比较内部数据并绕过内存中ID变化的方法。

示例:将您的类实例保存为字符串

使用模式2中的相同示例,可以按以下方式完成:

import streamlit as st from dataclasses import dataclass @dataclass class MyDataclass: var1: int var2: float def to_str(self): return f"{self.var1},{self.var2}" @classmethod def from_str(cls, serial_str): values = serial_str.split(",") var1 = int(values[0]) var2 = float(values[1]) return cls(var1, var2) if "my_dataclass" not in st.session_state: st.session_state.my_dataclass = MyDataclass(1, 5.5).to_str() # Displays True on every rerun MyDataclass.from_str(st.session_state.my_dataclass) == MyDataclass(1, 5.5) st.button("Rerun")

对于用作资源的类(数据库连接、状态管理器、API),考虑使用缓存的单例模式。使用@st.cache_resource来装饰类的@staticmethod,以生成类的单个缓存实例。例如:

import streamlit as st class MyResource: def __init__(self, api_url: str): self._url = api_url @st.cache_resource(ttl=300) @staticmethod def get_resource_manager(api_url: str): return MyResource(api_url) # This is cached until Session State is cleared or 5 minutes has elapsed. resource_manager = MyResource.get_resource_manager("http://example.com/api/")

当你在函数上使用Streamlit的缓存装饰器之一时,Streamlit不会使用函数对象来查找缓存值。相反,Streamlit的缓存装饰器使用函数的限定名称和模块来索引返回值。因此,尽管Streamlit在每次脚本运行时重新定义MyResourcest.cache_resource不会受此影响。get_resource_manager()将在每次重新运行时返回其缓存值,直到该值过期。

那么这里到底发生了什么?我们将通过一个简单的例子来说明为什么这是一个陷阱。如果你不想处理更多细节,可以跳过这一部分。你可以直接跳到学习使用Enum

暂时放下Streamlit,考虑一下这个简单的Python脚本:

from dataclasses import dataclass @dataclass class Student: student_id: int name: str Marshall_A = Student(1, "Marshall") Marshall_B = Student(1, "Marshall") # This is True (because a dataclass will compare two of its instances by value) Marshall_A == Marshall_B # Redefine the class @dataclass class Student: student_id: int name: str Marshall_C = Student(1, "Marshall") # This is False Marshall_A == Marshall_C

在这个例子中,数据类 Student 被定义了两次。所有三个 Marshall 实例具有相同的内部值。如果你比较 Marshall_AMarshall_B,它们将是相等的,因为它们都是从 Student 的第一个定义创建的。然而,如果你比较 Marshall_AMarshall_C,它们将不相等,因为 Marshall_C 是从 第二个 Student 定义创建的。尽管两个 Student 数据类的定义完全相同,但它们具有不同的内存 ID,因此是不同的。

在Streamlit中,你可能不会在页面脚本中两次编写相同的类。然而,Streamlit的重新运行逻辑会产生相同的效果。让我们用上面的例子进行类比。如果你在一个脚本运行中定义了一个类并将实例保存在Session State中,那么随后的重新运行将重新定义该类,你可能会在重新运行中将Mashall_C与Session State中的Marshall_A进行比较。由于小部件在底层依赖于Session State,这就是事情可能变得混乱的地方。

多个Streamlit UI元素,例如st.selectboxst.radio,通过options参数接受多项选择选项。您的应用程序的用户通常可以选择一个或多个这些选项。所选值由小部件函数返回。例如:

number = st.selectbox("Pick a number, any number", options=[1, 2, 3]) # number == whatever value the user has selected from the UI.

当你调用像st.selectbox这样的函数并将Iterable传递给options时,Iterable和当前选择会被保存到Session State的一个隐藏部分,称为Widget Metadata。

当您的应用程序用户与st.selectbox小部件交互时,浏览器会将其选择的索引发送到您的Streamlit服务器。此索引用于确定从原始options列表中返回哪些值,这些值保存在上一页执行的Widget Metadata中,并返回到您的应用程序。

关键细节是,st.selectbox(或类似的小部件函数)返回的值来自页面先前执行期间保存在Session State中的Iterable,而不是当前执行时传递给options的值。Streamlit之所以这样设计,有许多架构上的原因,我们在这里不深入讨论。然而,就是为什么当我们认为在比较同一类的实例时,实际上是在比较不同类的实例。

上述解释可能有点令人困惑,所以这里有一个极端的例子来说明这个想法。

import streamlit as st from dataclasses import dataclass @dataclass class Student: student_id: int name: str Marshall_A = Student(1, "Marshall") if "B" not in st.session_state: st.session_state.B = Student(1, "Marshall") Marshall_B = st.session_state.B options = [Marshall_A,Marshall_B] selected = st.selectbox("Pick", options) # This comparison does not return expected results: selected == Marshall_A # This comparison evaluates as expected: selected == Marshall_B

最后需要注意的是,我们在本节的示例中使用了@dataclass来说明一个观点,但实际上,这些问题在一般情况下也可能出现在类中。任何在比较运算符(如__eq____gt__)内部检查类身份的类都可能表现出这些问题。

来自Python标准库的Enum类是一种强大的方式,用于定义自定义符号名称,这些名称可以用作st.multiselectst.selectbox的选项,以替代str值。

例如,您可以在您的streamlit页面中添加以下内容:

from enum import Enum import streamlit as st # class syntax class Color(Enum): RED = 1 GREEN = 2 BLUE = 3 selected_colors = set(st.multiselect("Pick colors", options=Color)) if selected_colors == {Color.RED, Color.GREEN}: st.write("Hooray, you found the color YELLOW!")

如果您使用的是最新版本的Streamlit,这个Streamlit页面将按预期工作。当用户同时选择Color.REDColor.GREEN时,他们将看到特殊消息。

然而,如果你已经阅读了本页的其余部分,你可能会注意到一些棘手的事情。具体来说,每次运行此脚本时,EnumColor都会被重新定义。在Python中,如果你定义了两个具有相同类名、成员和值的Enum类,这些类及其成员仍然被认为是彼此唯一的。这应该会导致上述if条件始终评估为False。在任何脚本重新运行时,st.multiselect返回的Color值将与在该脚本运行中定义的Color属于不同的类。

如果您使用 Streamlit 1.28.0 或更早版本运行上述代码片段,您将无法看到特殊消息。幸运的是,从 1.29.0 版本开始,Streamlit 引入了一个配置选项,大大简化了这个问题。这就是默认启用的 enumCoercion 配置选项的用武之地。

当启用enumCoercion时,Streamlit会尝试识别您是否在使用像st.multiselectst.selectbox这样的元素,并将一组Enum成员作为选项。

如果 Streamlit 检测到这一点,它会将小部件返回的值转换为最新脚本运行中定义的 Enum 类的成员。这是我们称之为自动 Enum 强制转换的功能。

此行为可通过Streamlit config.toml文件中的enumCoercion设置进行配置。默认情况下是启用的,可以禁用或设置为更严格的匹配标准。

如果您发现启用enumCoercion后仍然遇到问题,请考虑使用上述描述的自定义类模式,例如将您的Enum类定义移动到单独的模块文件中。

forum

还有问题吗?

我们的 论坛 充满了有用的信息和Streamlit专家。