↓快轉到主要內容
  1. 教學文章/

Streamlit Form 提交生命週期:草稿、已套用狀態與失敗保留

·9 分鐘· loading · loading · ·
Python Streamlit Forms Session State Validation Testing
每日拍拍
作者
每日拍拍
科學家 X 科技宅宅
目錄
Python 學習 - 本文屬於一個選集。
§ 152: 本文

一. 前言:查詢失敗,不該順便抹掉上一次答案
#

拍拍君做資料小工具時,最怕這種畫面:剛查到一組有用的結果, 改了條件再送出,輸入驗證失敗,原本的表格也跟著消失。 更微妙的是,標題已經變成新條件,下面卻還留著舊結果。 這不是排版問題,而是「哪份狀態可以代表成功」沒有講清楚。

Streamlit 進階篇介紹過簡單表單; SQLModel 小後台則處理資料寫入。 今天不重做 CRUD,而是完成一個唯讀查詢的提交生命週期。 目標很具體:新嘗試可以失敗,最後成功套用的條件與結果必須仍然一致。

二. 三種狀態,不能混成一個 filters
#

狀態 放在哪裡 何時更新
未提交草稿 瀏覽器裡的 form widgets 使用者編輯時
最近送達的欄位值 伺服器 Widget Session State 表單提交,或程式主動重設時
最後成功快照 應用自己的 Session State key 驗證與查詢全部成功後

關鍵限制:Python 不能靠讀 st.session_state,即時知道表單內尚未送出的字。 即使點了表單外的按鈕引發 rerun,那些未提交修改也不會搭便車送達。 因此不要拿伺服器欄位和快照比較,就宣稱「已偵測瀏覽器草稿變更」。 若需要逐字預覽或即時 dirty indicator,應改用表單外 Widget 或另設前端協定。 批次提交與逐次同步,是不同的互動選擇。 這個邊界見 Streamlit 官方表單架構。

我們訂下四條規則:

  1. 初始化只顯示「尚未套用」,不偷跑查詢。
  2. Submit 先解析、驗證、查詢,最後才整份替換成功快照。
  3. 任何預期失敗都只更新錯誤,不覆寫成功快照。
  4. 無關 rerun 不查詢;重設則依明確按鈕語意執行。

這是一份同步、唯讀、小資料範例的契約,不是資料庫交易或非同步工作排程器。

三. 安裝:把環境先固定
#

以下範例以 Python 3.12、Streamlit 1.66.1、pytest 9.2.0 驗證。 建立空目錄後執行:

uv init --python 3.12
uv add 'streamlit==1.66.1'
uv add --dev 'pytest==9.2.0' 'pandas==3.1.0'

接著建立 query.py、app.py、test_lifecycle.py。 不用資料庫,也不用網路服務;先讓狀態轉移可重現,再接真實資料來源。 正式專案要保留 uv.lock,不要把「現在能跑」當成版本管理。

四. 成功快照:條件與結果一起存
#

把下面存成 query.py。資料只有三筆公開假資料。 !timeout 是刻意保留的測試輸入,會模擬服務失敗,不會真的連線或等待。

from dataclasses import dataclass
import re

CATALOG = ("拍拍咖啡", "拍拍餅乾", "拍拍筆記本")
DATA_VERSION = "demo-v1"

@dataclass(frozen=True)
class Query:
    needle: str = ""
    limit: int = 2

@dataclass(frozen=True)
class Snapshot:
    query: Query
    rows: tuple[str, ...]
    data_version: str

def parse_query(needle: str, limit_text: str) -> Query:
    needle = needle.strip()
    if len(needle) > 20:
        raise ValueError("關鍵字最多 20 字")
    if not re.fullmatch(r"[1-3]", limit_text):
        raise ValueError("筆數必須是 1、2 或 3")
    return Query(needle, int(limit_text))

def lookup(query: Query) -> tuple[str, ...]:
    if query.needle == "!timeout":
        raise TimeoutError("示範查詢逾時")
    return tuple(x for x in CATALOG if query.needle in x)[:query.limit]

def execute(needle: str, limit_text: str) -> Snapshot:
    query = parse_query(needle, limit_text)
    rows = lookup(query)
    return Snapshot(query, rows, DATA_VERSION)

快照不只存 rows,也存產生 rows 的合法條件與資料版本。 frozen=True 並非深層凍結所有 Python 物件;本例特別選用字串、整數、 tuple 和同樣 frozen 的 Query,避免快照裡藏著還能修改的 list。 畫面顯示時可以另外產生 list,但不要反過來修改快照。

輸入來源是兩個 text_input,所以 parser 的參數契約是字串。 內部程式應經過 execute 這個入口;dataclass 本身不會自動驗證型別或範圍。 筆數採嚴格單一數字,02、空白、全形數字和 2.0 都不接受。 這是這個介面的明確規則,不是所有產品都必須如此。

五. Callback:讀這次送到的值,不讀上輪捕捉的值
#

表單提交時,欄位的新值先同步到 Widget State,接著執行 submit callback, 然後 script 從頭重跑。因此 callback 內要用穩定的 key 讀取新值。 不要把上一次渲染時的 Widget 回傳值塞進 callback 的 args, 以為使用者按下按鈕時它會自動重新求值。 這個細節可對照 Session State callback 官方說明。

將下一段完整存成 app.py:

from dataclasses import asdict
import streamlit as st
from query import Query, execute

st.set_page_config(page_title="拍拍君提交實驗室")
ss = st.session_state
for key, value in {
    "ui_needle": "", "ui_limit": "2", "snapshot": None,
    "error": "", "attempts": 0, "successes": 0, "runs": 0,
}.items():
    if key not in ss:
        ss[key] = value

def submit():
    ss["attempts"] += 1
    try:
        candidate = execute(ss["ui_needle"], ss["ui_limit"])
    except (ValueError, TimeoutError) as exc:
        ss["error"] = str(exc)
        return
    ss["snapshot"] = candidate
    ss["successes"] += 1
    ss["error"] = ""

def reset(clear_result=False):
    if clear_result:
        ss["snapshot"] = None
        ss["attempts"] = 0
        ss["successes"] = 0
    old = ss["snapshot"]
    query = old.query if old is not None else Query()
    ss["ui_needle"] = query.needle
    ss["ui_limit"] = str(query.limit)
    ss["error"] = ""

ss["runs"] += 1
st.title("拍拍君提交實驗室")
st.button("無關重跑", key="rerun_only")
st.button("還原輸入", key="restore", on_click=reset)
st.button("全部重設", key="reset_all", on_click=reset, args=(True,))

with st.form("search", clear_on_submit=False, enter_to_submit=True):
    st.text_input("關鍵字", key="ui_needle")
    st.text_input("筆數(1~3)", key="ui_limit")
    st.form_submit_button("套用查詢", key="apply", on_click=submit)

if ss["error"]:
    st.error(ss["error"])
snapshot = ss["snapshot"]
if snapshot is None:
    st.info("尚未套用查詢")
else:
    st.subheader("上次成功套用")
    st.write(f"條件:{snapshot.query.needle!r};筆數:{snapshot.query.limit}")
    st.write(f"資料版本:{snapshot.data_version}")
    st.table({"名稱": list(snapshot.rows)})
    if not snapshot.rows:
        st.info("查詢成功,但沒有符合資料")

st.caption("以下只代表伺服器已知值,不是瀏覽器即時草稿")
st.json({
    "runs": ss["runs"], "attempts": ss["attempts"],
    "successes": ss["successes"],
    "server_needle": ss["ui_needle"], "server_limit": ss["ui_limit"],
    "applied": asdict(snapshot) if snapshot is not None else None,
})

成功分支只有一個快照替換點;不是先更新 applied 條件,再祈禱查詢成功。 Callback 只改狀態,畫面由下一輪 script 統一呈現,不需要再手動 st.rerun()。 args=(True,) 傳的是固定的重設模式,不是會隨輸入改變的欄位值。 只捕捉預期錯誤,沒有用 except Exception 把真正程式錯誤吞成驗證提示。 正式服務的錯誤訊息應轉成可公開的描述,不要照抄底層敏感資訊到畫面。

六. 還原、清空與失敗,分別代表什麼?
#

動作 輸入欄位 成功快照 計數
驗證或模擬逾時失敗 保留這次送達的值 不變 attempts +1
查詢成功,包含零筆結果 保留送達值 整份替換 attempts、successes +1
無關重跑 不主動改寫 不變 只有 runs +1
還原輸入 回最後成功條件;沒有則回預設 不變 不變
全部重設 回預設 清空 attempts、successes 歸零

我們採 clear_on_submit=False,因為失敗後使用者還需要修正輸入。 clear_on_submit=True 是提交後重設 Widget,不是「只有驗證成功才清空」; 也不會替你維護應用自己的 snapshot key。 參數語意見 st.form API。

兩個重設按鈕都放在表單外,用 callback 在 Widget 建立前指定欄位。 不要在本輪 st.text_input 已執行之後,再直接改同一個 Widget key。 還原輸入是捨棄草稿的明確操作;不是把瀏覽器草稿「儲存」到伺服器。 範例刻意保留穩定的 form 與 Widget keys,不涵蓋動態移除 Widget 的清理生命週期。

七. AppTest:守住成功快照,不冒充瀏覽器
#

下面存成 test_lifecycle.py。 前面四個參數案例守住 parser,後面檢查實際 callback 與畫面。

from dataclasses import FrozenInstanceError
import pandas  # 先載入 st.table 使用的原生相依,避免計入 AppTest 單輪逾時
import pytest
from streamlit.testing.v1 import AppTest
from query import execute

@pytest.mark.parametrize("limit", ["0", "4", "02", "2"])
def test_bad_limit(limit):
    with pytest.raises(ValueError):
        execute("拍拍", limit)

def start():
    at = AppTest.from_file("app.py", default_timeout=15).run()
    assert not at.exception
    return at

def apply(at, needle, limit="2"):
    at.text_input(key="ui_needle").set_value(needle)
    at.text_input(key="ui_limit").set_value(limit)
    at.button(key="apply").click().run()
    assert not at.exception

def test_initial_and_unrelated_rerun():
    at = start()
    at.button(key="rerun_only").click().run()
    assert at.session_state["attempts"] == 0
    assert at.session_state["snapshot"] is None
    assert at.session_state["runs"] == 2

def test_fresh_values_and_frozen_snapshot():
    at = start()
    apply(at, "咖啡", "1")
    snap = at.session_state["snapshot"]
    assert (snap.query.needle, snap.query.limit) == ("咖啡", 1)
    assert snap.rows == ("拍拍咖啡",)
    with pytest.raises(FrozenInstanceError):
        snap.query.limit = 3

@pytest.mark.parametrize("needle,limit", [("餅乾", "0"), ("!timeout", "2")])
def test_failed_attempt_keeps_success(needle, limit):
    at = start()
    apply(at, "咖啡", "1")
    before = at.session_state["snapshot"]
    apply(at, needle, limit)
    assert at.session_state["snapshot"] == before
    assert at.session_state["ui_needle"] == needle
    assert (at.session_state["attempts"], at.session_state["successes"]) == (2, 1)
    assert len(at.error) == 1
    assert at.table[0].value["名稱"].tolist() == ["拍拍咖啡"]
    at.button(key="rerun_only").click().run()
    assert at.session_state["snapshot"] == before
    assert at.session_state["attempts"] == 2

def test_restore_then_reset():
    at = start()
    apply(at, "咖啡", "1")
    apply(at, "!timeout")
    at.button(key="restore").click().run()
    assert at.text_input(key="ui_needle").value == "咖啡"
    assert at.text_input(key="ui_limit").value == "1"
    assert not at.error
    assert at.session_state["attempts"] == 2
    at.button(key="reset_all").click().run()
    assert not at.exception
    assert at.session_state["snapshot"] is None
    assert at.text_input(key="ui_needle").value == ""
    assert at.text_input(key="ui_limit").value == "2"
    assert at.session_state["attempts"] == at.session_state["successes"] == 0

def test_zero_rows_is_success_and_clears_error():
    at = start()
    apply(at, "!timeout")
    assert at.session_state["snapshot"] is None
    apply(at, "不存在")
    assert not at.error
    assert at.session_state["snapshot"].rows == ()
    assert at.session_state["successes"] == 1

執行測試,再啟動 App:

uv run pytest -q
uv run streamlit run app.py

AppTest 的 .set_value() 是設定測試狀態,.run() 才會執行模擬 App。 測試模組先載入 pandas,把冷啟動的原生模組載入與 App 單輪執行預算分開; 每輪仍有 15 秒上限,不是無限等待。這也不代表已量測正式部署的冷啟動速度。 它能證明 submit callback 讀到新值、失敗保留與 reset 契約, 不能證明真實瀏覽器尚未提交時沒有傳送欄位,也不能證明 Enter 行為。 這兩件事要留給下一層,不要用同一份測試報告混過去。 AppTest 的基本操作可另看先前的測試專篇。

八. 瀏覽器驗收:真的打字,再看伺服器知道多少
#

本機 Chromium 另外檢查以下流程,這不是 AppTest 的測試數字:

  1. 初始 attempts 為 0;在關鍵字輸入「咖啡」、筆數輸入 1,移開焦點。 頁面 runs 不變,伺服器快照仍是預設值,代表尚未提交。
  2. 點表單外的「無關重跑」,確認 attempts 仍為 0,server 值仍未接收草稿。
  3. 在欄位按 Enter,確認唯一的 submit 按鈕被觸發,兩個新值一起送達。
  4. 改成餅乾、筆數 0 再送出,確認欄位保留錯誤值,但表格仍是咖啡。
  5. 送出 !timeout,再按無關重跑,確認最後成功結果沒有消失或偷偷重查。
  6. 點還原輸入,確認回咖啡與 1;點全部重設,確認回預設且結果清空。

這裡只有一個 submit 按鈕,enter_to_submit=True。 本篇的 10 項 pytest/AppTest 與另外 10 組本機 Chromium 檢查皆通過; 瀏覽器也確認零筆結果算成功,以及重設後結果與計數清空。 若改成多個 submit,Enter 預設對應第一個;第一個被 disabled 時另有規則, 請依自己的介面重測,不要把這份驗收外推到所有 Widget、手機鍵盤或瀏覽器。 我們只以本機 Chromium 驗證這個單行文字輸入流程,未宣稱 Safari/行動裝置覆蓋。

九. 這份契約沒有保證什麼?
#

快照存在目前的 Session State,不是持久化儲存。 重新載入建立新 session,不能期待它像資料庫一樣永久保留。 資料版本 demo-v1 也只是固定假資料的標記;接真實服務後, 應由查詢回應提供可追溯版本或時間,不要自己貼一個字串冒充一致性。

每一次 submit 都是一次嘗試,重複點擊可以再次執行。 這裡沒有 exactly-once 保證;若換成付款、寄信或寫資料庫, 必須另外設計冪等鍵、授權與交易,而不是在 callback 外面加布林旗標就算完成。 若改成背景任務,還要處理較舊工作晚回來覆蓋新結果的版本競爭。 這篇的同步小範例沒有解決那個問題。

結語:表單是提交邊界,不是成功的同義詞
#

拍拍君的記法是:草稿留在瀏覽器,提交值先被驗證,成功才換整份快照。 失敗時保留輸入供修正,也保留最後成功結果供參考,兩者不用互相犧牲。 再把還原和完整重設分開,使用者就不必猜「這顆按鈕到底會清掉什麼」。 狀態有名字,畫面才有可信的意思。

延伸閱讀
#

Python 學習 - 本文屬於一個選集。
§ 152: 本文

相關文章

Streamlit URL 狀態契約:Query Params、篩選還原與雙向同步
·8 分鐘· loading · loading
Python Streamlit Query-Params URL State Testing
Streamlit SQL 連線實戰:st.connection、查詢快取 TTL 與交易邊界
·7 分鐘· loading · loading
Python Streamlit SQL SQLConnection Cache Testing
Streamlit Custom Components:雙向事件、狀態同步、打包與測試
·8 分鐘· loading · loading
Python Streamlit Custom Components JavaScript State Testing Packaging
Streamlit Data Editor 實戰:可編輯表格、上傳驗證與 CSV 匯入匯出
·8 分鐘· loading · loading
Python Streamlit Data-Editor CSV Validation Developer-Tools
Streamlit AppTest 實戰:用 pytest 測 Widgets、Session State 與 CI
·9 分鐘· loading · loading
Python Streamlit AppTest Pytest Testing Session State Ci
Textual Form Wizard 實戰:多步驟表單、Validation 與狀態切換
·5 分鐘· loading · loading
Python Textual TUI Forms Validation Developer-Tools