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

Streamlit URL 狀態契約:Query Params、篩選還原與雙向同步

·8 分鐘· loading · loading · ·
Python Streamlit Query-Params URL State Testing
每日拍拍
作者
每日拍拍
科學家 X 科技宅宅
目錄
Python 學習 - 本文屬於一個選集。
§ 147: 本文

一. 前言:你看到的篩選,不一定在分享連結裡
#

「我把連結傳給你了,你怎麼看到全部資料?」

拍拍君很熟悉這種場面:自己的 Dashboard 已選好地區和標籤, 同事打開連結卻回到預設畫面。因為 Widget 狀態活在 Session State, 不代表它也活在 URL。

反過來,直接把 URL 每輪塞回 Widget,也可能讓剛剛的選擇被舊參數蓋掉。 今天不重做 Dashboard,而是替「可分享的篩選條件」訂一份小契約。

如果還不熟 key 與 rerun,先看Streamlit 進階篇。 這次往下挖的是輸入邊界與同步順序,不是另一輪 cache 教學。

二. 先決定:什麼能放進 URL?
#

這份範例只有三種公開條件:

參數 領域型別 預設 異常策略
region all / north / south all 重複或未知值回預設
tag coffee / cookie / notebook 的集合 空集合=不限 去重、排序、丟棄未知值
page 1~50 的整數 1 非法格式或超界回 1

空標籤代表「不限制標籤」,不是「查不到任何資料」。 這是產品語意,必須先說清楚,否則序列化再漂亮也沒有用。 單值參數若重複,我們不偷偷選最後一個;多值參數則明確允許重複 key。 未知 key 會被移除,因為這個獨立範例擁有整份可操作的 query string。

URL 是公開輸入,不是可信身分。 不要放 Token、密碼、私人搜尋字串;也不能用 ?role=admin 當授權。 後端資料權限仍須獨立檢查,連結可被改寫。 瀏覽紀錄、截圖或存取紀錄都可能留下 URL,HTTPS 並不會消除這些副本。

三. 安裝與最小專案
#

以下程式使用 Python 3.12、Streamlit 1.66.1、pytest 9.1.1 驗證。 在新的空目錄執行:

uv init --python 3.12
uv add 'streamlit==1.66.1'
uv add --dev 'pytest==9.1.1'

我們只建立 filters.py、app.py、test_filters.py、test_app.py。 不需要資料庫,先把狀態接線證明正確,再接實際資料查詢。 正式專案應提交 uv.lock,在 CI 使用鎖定的依賴。

四. Decoder:先把字串變成合法條件
#

建立 filters.py。這層不 import Streamlit,可以單獨測試。 輸入統一採用 dict[str, list[str]],避免一開始就丟失重複 key。

from dataclasses import dataclass
import re

REGIONS = ("all", "north", "south")
TAGS = ("coffee", "cookie", "notebook")

@dataclass(frozen=True)
class Filters:
    region: str = "all"
    tags: tuple[str, ...] = ()
    page: int = 1

def single(raw, key, default):
    values = raw.get(key, [])
    return values[0] if len(values) == 1 else default

def decode(raw: dict[str, list[str]]) -> Filters:
    region = single(raw, "region", "all")
    if region not in REGIONS:
        region = "all"
    text = single(raw, "page", "1")
    # 先限長度與 ASCII 格式,再轉 int;不接受 +2、2.0、全形數字。
    page = int(text) if re.fullmatch(r"[0-9]{1,2}", text) else 1
    if not 1 <= page <= 50:
        page = 1
    tags = tuple(sorted(set(raw.get("tag", [])) & set(TAGS)))
    return Filters(region, tags, page)

def encode(filters: Filters) -> dict[str, list[str]]:
    result = {}
    if filters.region != "all":
        result["region"] = [filters.region]
    if filters.tags:
        result["tag"] = list(filters.tags)
    if filters.page != 1:
        result["page"] = [str(filters.page)]
    return result

def signature(raw):
    return tuple(sorted((key, tuple(values)) for key, values in raw.items()))

這不是通用 URL 防火牆;decode 的輸入型別假設來自 Streamlit 的字串列表。 encode 則只接受 Decoder 或受限 Widget 產生的合法 Filters。 若開放其他 Python 呼叫端自行建立 Filters,應另加建構驗證。

page=02 接受為第 2 頁,寫回時變成 page=2。 page=999999 不先做無界整數轉換,而是直接回預設。 未知標籤被丟棄後若集合為空,就採用「不限」;若業務不能接受放寬, 應改成阻擋查詢並顯示錯誤,而不是照抄這個寬容策略。

五. Canonical:同一份條件,只留一種表示
#

我們不追求保留使用者貼上的原始字面形式,而是收斂到穩定表示:

輸入:?region=all&tag=cookie&tag=coffee&tag=coffee&page=02&x=old
輸出:?tag=coffee&tag=cookie&page=2

預設值省略、標籤排序去重、未知 key 移除。 不同 key 的排列不算狀態變化,因此 signature 會排序 key; 同一 key 的值則保留順序,讓標籤順序仍可被正規化。 這裡的 canonical 指應用狀態表示,不是搜尋引擎的 canonical HTML 標籤。

兩條很值得測的性質是:

  1. 合法條件經過 encode → decode,不改變意思。
  2. 任意輸入經過一次正規化,再正規化不會繼續變動。

st.query_params 的一般字典讀取遇到重複 key 只取最後值; 多值必須用 get_all(),寫回則可傳列表。 整份更新使用 from_dict(),不要先 clear 再逐項寫入。 API 行為見官方 Query Params 文件。

六. 雙向同步:每次事件只選一個權威來源
#

先訂順序,再寫程式:

事件 本輪權威 處理
首次開啟、重新載入、導向另一條 URL URL decode → 初始化 Widget → 正規化 URL
使用者操作 Widget Widget callback 讀新值 → 寫 URL → 記住快照
無關按鈕引發 rerun 現有狀態 快照相同,不重新播種 Widget
按重設 預設條件 callback 同時更新 Widget 和 URL

建立 app.py:

from dataclasses import asdict
import streamlit as st
from filters import Filters, REGIONS, TAGS, decode, encode, signature

st.set_page_config(page_title="拍拍君公開篩選")
ss = st.session_state

def read_query():
    return {key: st.query_params.get_all(key) for key in st.query_params}

def seed(filters):
    ss["ui_region"] = filters.region
    ss["ui_tags"] = list(filters.tags)
    ss["ui_page"] = filters.page

def write_query(filters):
    wanted = encode(filters)
    if signature(read_query()) != signature(wanted):
        st.query_params.from_dict(wanted)
    ss["_url_seen"] = signature(wanted)

def from_widgets():
    return Filters(
        ss["ui_region"], tuple(sorted(ss["ui_tags"])), int(ss["ui_page"])
    )

def commit_widgets():
    write_query(from_widgets())

def reset():
    seed(Filters())
    write_query(Filters())

raw = read_query()
if ss.get("_url_seen") != signature(raw):
    restored = decode(raw)
    seed(restored)
    write_query(restored)

st.title("拍拍君公開篩選")
st.selectbox("地區", REGIONS, key="ui_region", on_change=commit_widgets)
st.multiselect("標籤", TAGS, key="ui_tags", on_change=commit_widgets)
st.number_input("頁碼", 1, 50, step=1, key="ui_page", on_change=commit_widgets)
st.button("重設", key="reset", on_click=reset)
st.button("重新整理畫面", key="refresh")
current = from_widgets()
st.json(asdict(current))
ss["_runs"] = ss.get("_runs", 0) + 1
st.caption(f"執行輪數:{ss['_runs']}")

_url_seen 是「程式上次已處理的原始參數快照」,不是第二份產品狀態。 callback 寫 URL 時也記錄快照,因此後續 rerun 不會誤判成外部導向, 再拿舊值蓋掉剛完成的 Widget 操作。 實際查詢應使用 current,而不是重新相信未驗證的 URL 字串。

這份程式沒有額外呼叫 st.rerun()。 它只在參數真的不同時才更新 URL,避免無條件寫入與強制 rerun 互相追逐。 _runs 是診斷計數;閒置時若一直增加,先找背景刷新或迴圈。

七. 初始化順序與重設,為什麼要這樣寫?
#

Widget 建立前才播種 session_state;不要先畫 selectbox,再直接修改它的 key。 重設放在 callback,讓它在下一輪主程式建立 Widget 前執行。 只刪 Widget key 不代表會清掉前端既有值,所以這裡明確寫回預設值。

callback 也不傳入「上一次執行時抓到的 Widget 值」,而是從 Session State 讀取。 這利用了 Streamlit 的事件順序:更新 Widget 狀態、執行 callback、再重跑頁面。 細節可對照官方 Widget 行為說明。

目前版本也有 bind="query-params",簡單的一對一映射可優先評估內建綁定。 本例刻意不用它,因為我們要求重複單值拒收、固定白名單和省略預設值。 不要讓內建綁定與這份手動同步同時擁有同一個參數,否則權威來源又變成兩個。

範例是單頁 App,不保證跨頁自動保留全部條件。 嵌入參數也不屬於這份契約;不要試圖用 st.query_params 改寫保留的 embed 設定。 若 URL 還有其他模組管理的參數,應先設計命名空間及合併規則,再改寫整份 mapping。

八. 純函式測試:異常 URL 也要能收斂
#

建立 test_filters.py:

import pytest
from filters import Filters, decode, encode

@pytest.mark.parametrize("text", ["", "0", "51", "-1", "+2", "2.0", "2", "9" * 100])
def test_bad_page(text):
    assert decode({"page": [text]}).page == 1

def test_duplicates_and_allowlist():
    result = decode({"region": ["north", "south"],
                     "tag": ["cookie", "coffee", "cookie", "unknown"]})
    assert result == Filters("all", ("coffee", "cookie"), 1)
    assert decode({"page": ["2", "3"]}).page == 1

@pytest.mark.parametrize("raw", [
    {}, {"region": ["invalid"]}, {"page": ["02"]},
    {"region": ["all"], "tag": ["cookie", "coffee", "cookie"], "x": ["old"]},
])
def test_canonical_converges(raw):
    once = encode(decode(raw))
    assert encode(decode(once)) == once
    assert decode(once) == decode(raw)

這裡不是證明所有 URL 都安全,而是確認我們宣告的 admission 與序列化政策。 真實服務仍需在入口限制 URL 長度、參數數量及請求成本。 省略預設值也有版本代價:未來若把預設地區從 all 改成 north,舊空連結的意思就變了。 需要長期重現時,應加 schema 版本或明確輸出關鍵預設值,並測試舊版遷移。

九. AppTest:測接線,不冒充瀏覽器
#

建立 test_app.py:

from streamlit.testing.v1 import AppTest

def test_restore_edit_external_reset():
    at = AppTest.from_file("app.py")
    at.query_params.update({"region": ["north"], "tag": ["cookie", "coffee"]})
    at.run()
    assert not at.exception
    assert at.selectbox("ui_region").value == "north"
    assert at.multiselect("ui_tags").value == ["coffee", "cookie"]
    at.selectbox("ui_region").set_value("south").run()
    assert at.query_params["region"] == "south"
    at.button("refresh").click().run()
    assert at.selectbox("ui_region").value == "south"
    at.query_params.clear()
    at.query_params.update({"region": ["north"], "page": ["2"]})
    at.run()
    assert at.selectbox("ui_region").value == "north"
    assert at.number_input("ui_page").value == 2
    at.button("reset").click().run()
    assert not at.exception
    assert at.query_params == {}
    assert at.multiselect("ui_tags").value == []
    assert at.number_input("ui_page").value == 1
    for _ in range(3):
        at.run()
        assert at.query_params == {}
        assert not at.exception

執行 uv run pytest -q。若不熟 AppTest 的操作方式, 可回頭看專門的 AppTest 教學,這裡只測 URL 契約。 修改 at.query_params 再 run 是模擬伺服器收到另一份參數, 並沒有真的測到瀏覽器網址列、WebSocket 或 History API。

十. 真實瀏覽器驗收:把另一半補起來
#

啟動後,開啟 http://localhost:8501/?region=north&tag=cookie&tag=coffee&page=02:

uv run streamlit run app.py --server.address 127.0.0.1

至少分開確認下面幾條路徑:

  • 深連結開啟:Widget 還原,URL 多值去重排序,頁碼變成 2。
  • 操作地區、標籤、頁碼:URL 反映新值;無關 rerun 不把它蓋回。
  • 複製當前連結到新分頁:新 Session 仍能還原同一份公開條件。
  • 貼入重複 region、非法 page、未知標籤:畫面採取表格宣告的回退策略。
  • 按重設後重新載入:Widget 與 URL 都維持預設,不殘留舊標籤。
  • 閒置觀察執行輪數:沒有自行連續 rerun。

本篇的 14 項 pytest/AppTest 與 10 組本機 Chromium 驗收皆通過, 包含上述操作、重載與閒置檢查;兩種測試結果分開記錄。 不把 Chromium 的通過擴張成 Safari、手機、反向代理部署也全部通過。 瀏覽器上一頁/下一頁能否逐次還原每一次 Widget 操作,也不在這份保證內: 那取決於框架與瀏覽器如何更新歷史,不是雙向同步自然附贈的功能。 需要可回放的操作歷史,請另訂導覽協定並做真實瀏覽器測試。

結語:分享的是可重建的條件,不是剛好的畫面
#

拍拍君的結論很簡單:URL 狀態要先有 schema,再有同步。 把原始字串轉成合法條件、替重複 key 訂政策、讓序列化收斂, 最後才把 URL 與 Widget 接起來。 當每個事件都有唯一權威來源,「剛選完又跳回去」就不再是玄學。

延伸閱讀
#

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

相關文章

Streamlit Custom Components:雙向事件、狀態同步、打包與測試
·8 分鐘· loading · loading
Python Streamlit Custom Components JavaScript State Testing Packaging
Streamlit SQL 連線實戰:st.connection、查詢快取 TTL 與交易邊界
·7 分鐘· loading · loading
Python Streamlit SQL SQLConnection Cache Testing
Streamlit AppTest 實戰:用 pytest 測 Widgets、Session State 與 CI
·9 分鐘· loading · loading
Python Streamlit AppTest Pytest Testing Session State Ci
Streamlit 檔案傳輸邊界:上傳配額、內容檢查與下載生命週期
·8 分鐘· loading · loading
Python Streamlit File-Upload Download Memory Lifecycle
Textual Modes 實戰:Workspace 切換、畫面生命週期與路由測試
·7 分鐘· loading · loading
Python Textual TUI Modes Screen-Routing Lifecycle Testing
Python asyncio.Queue 實戰:背壓、Graceful Shutdown 與不丟資料測試
·8 分鐘· loading · loading
Python Asyncio Queue Backpressure Graceful Shutdown Concurrency Testing