一. 前言:查詢失敗,不該順便抹掉上一次答案 #
拍拍君做資料小工具時,最怕這種畫面:剛查到一組有用的結果, 改了條件再送出,輸入驗證失敗,原本的表格也跟著消失。 更微妙的是,標題已經變成新條件,下面卻還留著舊結果。 這不是排版問題,而是「哪份狀態可以代表成功」沒有講清楚。
Streamlit 進階篇介紹過簡單表單; SQLModel 小後台則處理資料寫入。 今天不重做 CRUD,而是完成一個唯讀查詢的提交生命週期。 目標很具體:新嘗試可以失敗,最後成功套用的條件與結果必須仍然一致。
二. 三種狀態,不能混成一個 filters #
| 狀態 | 放在哪裡 | 何時更新 |
|---|---|---|
| 未提交草稿 | 瀏覽器裡的 form widgets | 使用者編輯時 |
| 最近送達的欄位值 | 伺服器 Widget Session State | 表單提交,或程式主動重設時 |
| 最後成功快照 | 應用自己的 Session State key | 驗證與查詢全部成功後 |
關鍵限制:Python 不能靠讀 st.session_state,即時知道表單內尚未送出的字。
即使點了表單外的按鈕引發 rerun,那些未提交修改也不會搭便車送達。
因此不要拿伺服器欄位和快照比較,就宣稱「已偵測瀏覽器草稿變更」。
若需要逐字預覽或即時 dirty indicator,應改用表單外 Widget 或另設前端協定。
批次提交與逐次同步,是不同的互動選擇。
這個邊界見 Streamlit 官方表單架構。
我們訂下四條規則:
- 初始化只顯示「尚未套用」,不偷跑查詢。
- Submit 先解析、驗證、查詢,最後才整份替換成功快照。
- 任何預期失敗都只更新錯誤,不覆寫成功快照。
- 無關 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 的測試數字:
- 初始 attempts 為 0;在關鍵字輸入「咖啡」、筆數輸入 1,移開焦點。 頁面 runs 不變,伺服器快照仍是預設值,代表尚未提交。
- 點表單外的「無關重跑」,確認 attempts 仍為 0,server 值仍未接收草稿。
- 在欄位按 Enter,確認唯一的 submit 按鈕被觸發,兩個新值一起送達。
- 改成餅乾、筆數 0 再送出,確認欄位保留錯誤值,但表格仍是咖啡。
- 送出
!timeout,再按無關重跑,確認最後成功結果沒有消失或偷偷重查。 - 點還原輸入,確認回咖啡與 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 外面加布林旗標就算完成。 若改成背景任務,還要處理較舊工作晚回來覆蓋新結果的版本競爭。 這篇的同步小範例沒有解決那個問題。
結語:表單是提交邊界,不是成功的同義詞 #
拍拍君的記法是:草稿留在瀏覽器,提交值先被驗證,成功才換整份快照。 失敗時保留輸入供修正,也保留最後成功結果供參考,兩者不用互相犧牲。 再把還原和完整重設分開,使用者就不必猜「這顆按鈕到底會清掉什麼」。 狀態有名字,畫面才有可信的意思。