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

Textual RichLog 串流檢視器:有界緩衝、暫停追蹤與批次刷新

·7 分鐘· loading · loading · ·
Python Textual RichLog TUI Streaming-Logs Bounded-Buffer
每日拍拍
作者
每日拍拍
科學家 X 科技宅宅
目錄
Python 學習 - 本文屬於一個選集。
§ 144: 本文

一. 前言:Log 一直滾,錯誤卻看不到
#

拍拍君寫了一個會吐 log 的工具。 剛開始每秒三筆很可愛,換成批次作業後,每秒幾百筆就變成文字瀑布。 往上捲想看錯誤,新事件又把畫面拉回底部;按下暫停,記憶體反而一路長。

問題不只是「加一個 RichLog」。 來源、輸入緩衝、歷史與畫面,必須各自有上限和語意。 今天做的是觀測用的串流檢視器,不是日誌儲存服務,也不是工作執行佇列。 若需要長任務與取消,先看 Textual Background Workers。 若要不可丟工作的背壓管線,請看 asyncio.Queue。

二. 先訂四層契約,再談 Widget
#

層次 本文上限 超出時的行為
單筆入口 原始字串 4096 字元 拒絕並累加 rejected
Pending 2000 筆、每筆最多 240 字元 丟最舊的一筆並累加 dropped
History 最近 1000 筆已接收事件 淘汰舊資料並累加 retired
RichLog 最多 500 個渲染行 移除最舊渲染行

這是保最新、容許觀測資料損失的政策。 dropped 是還沒進入歷史就被擠掉的事件,不是處理成功數。 retired 是已進歷史、如今超出保留窗的事件;過濾不會增加它。 來源若是稽核紀錄,應先寫入可靠儲存,再送觀測副本給 UI。 不要因為畫面有一個計數器,就宣稱資料不會丟。

三. 安裝與操作目標
#

本文範例以 Python 3.12、Textual 8.2.8、Rich 15.0.0 實測。 RichLog 官方文件 說明了渲染行上限與捲動參數。 API 行為以釘選版本為準,升級後請重跑下面的互動測試。

uv init pypy-log-viewer
cd pypy-log-viewer
uv add 'textual==8.2.8' 'rich==15.0.0'
uv add --dev pytest pytest-asyncio

只需要 viewer.py 與 test_viewer.py。 啟動後,示範來源每 0.1 秒產生 30 筆人工 heartbeat,並非外部服務流量測量。 輸入框做不分大小寫的字面子字串查詢,不接受正規表示式。 Ctrl+P 凍結/恢復畫面,Ctrl+F 開關追蹤,Ctrl+Q 離開。 程式停用預設 Command Palette,避免 Ctrl+P 衝突;正式工具可改用其他按鍵。

四. 完整實作:把 Producer 擋在 Widget 外面
#

把下面整段存成 viewer.py。 Inbox.offer() 可以從其他 thread 呼叫;Widget 與 history 只能由 UI thread 操作。 這裡直接用同步 timer 模擬來源,不額外建立 Worker,以便看清顯示管線本身。

from collections import deque
from threading import Lock
import re
import unicodedata

from rich.text import Text
from textual.app import App, ComposeResult
from textual.widgets import Footer, Input, RichLog, Static

PENDING = 2000
HISTORY = 1000
VISIBLE = 500
BATCH = 100
MAX_RAW = 4096
SECRET = re.compile(r"(?i)\b(token|password)=\S+")


def clean(message: str) -> str:
    message = SECRET.sub(r"\1=[REDACTED]", message)
    message = message.replace("\n", " ↵ ").replace("\r", " ")
    return "".join(c for c in message
                   if not unicodedata.category(c).startswith("C"))[:240]


class Inbox:
    def __init__(self, capacity: int = PENDING):
        if capacity < 1:
            raise ValueError("capacity must be positive")
        self.capacity = capacity
        self.rows: deque[str] = deque()
        self.lock = Lock()
        self.dropped = 0
        self.rejected = 0

    def offer(self, message: str) -> None:
        # Only strings enter this boundary; reject oversized input before regex.
        if not isinstance(message, str) or len(message) > MAX_RAW:
            with self.lock:
                self.rejected += 1
            return
        row = clean(message)
        with self.lock:
            if len(self.rows) == self.capacity:
                self.rows.popleft()  # Keep the newest events, count the loss.
                self.dropped += 1
            self.rows.append(row)

    def drain(self, limit: int = BATCH):
        with self.lock:
            rows = [self.rows.popleft()
                    for _ in range(min(limit, len(self.rows)))]
            return rows, len(self.rows), self.dropped, self.rejected


class Viewer(App):
    CSS = """
    #stats { height: 2; }
    Input { height: 3; }
    RichLog { height: 1fr; border: solid $accent; }
    """
    BINDINGS = [
        ("ctrl+p", "pause_view", "暫停畫面"),
        ("ctrl+f", "follow", "追蹤尾端"),
        ("ctrl+q", "quit", "離開"),
    ]
    ENABLE_COMMAND_PALETTE = False

    def __init__(self, inbox: Inbox | None = None, demo: bool = True):
        super().__init__()
        self.inbox = inbox if inbox is not None else Inbox()
        self.history: deque[str] = deque(maxlen=HISTORY)
        self.retired = 0
        self.paused = False
        self.following = True
        self.dirty = True
        self.needle = ""
        self.demo = demo
        self.sequence = 0

    def compose(self) -> ComposeResult:
        yield Static(id="stats", markup=False)
        yield Input(placeholder="字面子字串過濾(不分大小寫)", max_length=80)
        yield RichLog(id="log", max_lines=VISIBLE, min_width=1,
                      markup=False, highlight=False, wrap=False,
                      auto_scroll=False)
        yield Footer()

    def on_mount(self) -> None:
        self.set_interval(0.1, self.flush)
        if self.demo:
            self.set_interval(0.1, self.produce)

    def produce(self) -> None:
        for _ in range(30):
            self.sequence += 1
            level = "ERROR" if self.sequence % 17 == 0 else "INFO"
            self.inbox.offer(f"{self.sequence:06d} {level} 拍拍君 task heartbeat")

    def on_input_changed(self, event: Input.Changed) -> None:
        self.needle = event.value.casefold()
        self.dirty = True

    def action_pause_view(self) -> None:
        self.paused = not self.paused
        if not self.paused:
            self.dirty = True

    def action_follow(self) -> None:
        self.following = not self.following
        if self.following and not self.paused:
            self.query_one(RichLog).scroll_end(animate=False)

    def flush(self) -> None:
        rows, pending, dropped, rejected = self.inbox.drain()
        self.retired += max(0, len(self.history) + len(rows) - HISTORY)
        self.history.extend(rows)
        state = "PAUSED" if self.paused else "LIVE"
        self.query_one("#stats", Static).update(
            f"{state} follow={self.following} pending={pending} "
            f"dropped={dropped} rejected={rejected}\n"
            f"history={len(self.history)}/{HISTORY} retired={self.retired}"
        )
        log = self.query_one(RichLog)
        if self.paused or not log.size.width or not log.size.height:
            self.dirty = True
            return
        source = self.history if self.dirty else rows
        selected = [row for row in source if self.needle in row.casefold()]
        if self.dirty:
            log.clear()
        if selected:
            log.write(Text("\n".join(selected[-VISIBLE:])),
                      scroll_end=self.following, animate=False)
        self.dirty = False


if __name__ == "__main__":
    Viewer().run()
uv run python viewer.py

五. 為什麼 max_lines 還不夠?
#

RichLog(max_lines=500) 限制的是渲染結果,不會限制你另外建立的 list 或 queue。 一個多行 traceback 也可能變成很多渲染行,不能把 500 行當成 500 個事件。 本文先把換行變成可見記號,關閉 wrap,讓一般文字事件保持單行。 橫向太長可以捲動,不會把每筆任意展開成數十行。

仍然要限制單筆長度,否則一筆超長文字就可能耗盡渲染時間。 這裡在 regex 前拒絕超過 4096 字元的輸入,再把保留文字截成 240 字元。 它不是 socket 的讀取上限:上游讀檔或解碼也必須先限制 bytes 與單行長度。 若上游已一次讀進 10 GB,這個入口無法替你收回先前的記憶體。

Queue 和 history 加起來最多保留約 72 萬字元的字串內容。 這不是 RSS 的 720 KB 保證:Unicode、Python 物件、deque、Rich Strip、快取都有額外成本。 另有最多 500 個渲染行與暫存批次;實際服務仍要量 RSS、延遲與併發來源數。 有界資料結構保證保留量不會隨執行時間無限增加,不等於硬即時或零配置。

六. 暫停畫面,不等於停止接收
#

本文刻意把三件事拆開:

  • 暫停:不清除、不寫入 RichLog,保留當下可閱讀的畫面。
  • 追蹤關閉:仍附加文字,但不主動捲到尾端。
  • 來源停止:不再產生事件,本篇沒有把它綁在暫停鍵上。

暫停期間仍以每次最多 100 筆排空 Inbox,history 也繼續輪替。 因此不會把所有新資料堆在暫停 backlog 裡。 恢復時重建的是「當下保留窗中符合條件的最後 500 筆」,不是補播全部漏看的資料。 暫停很久,舊資料就可能已退休;要回查完整歷史,請查正式日誌儲存。

輸入過濾字串時,如果仍在暫停,畫面保持不變,直到恢復才套用新條件。 這個政策讓「凍結」真的可預測;若產品要即時查詢凍結快照,應另存有界快照並明訂語意。 關閉 follow 也不是精確位置鎖定:舊渲染行被淘汰時,可見內容仍可能移動。 切換過濾或恢復都會 clear/rebuild,捲動位置會重設;只有未重建的附加路徑保留原有捲動狀態。

七. 批次刷新:不要每筆事件送一個 UI Message
#

offer() 只做小型文字整理與持鎖入列,不呼叫 post_message() 或 Widget。 Producer 的呼叫速度仍要有上游限制;這個 lock 不是無等待的 lock-free queue。 flush() 每 0.1 秒最多取 100 筆,再合成一個 Text,每個非空批次只呼叫一次 write()。 若輸入長期快於消化速度,pending 會滿、dropped 會上升,畫面會明白呈現損失。

一般 tick 只過濾新批次;改條件或恢復時,才掃描有界的 1000 筆 history。 這比每 100 ms 清空再重畫全部內容,更容易控制工作量與閱讀穩定性。 1000 筆/秒只是由 batch 和理想 timer 間隔推算的排空上限,不是實測吞吐保證。 忙碌的 event loop、寬字元和終端速度都會改變實際延遲。

每次 filter rebuild 最多送 500 行;縮小到沒有可用尺寸時先不寫入。 RichLog write 說明 提醒,尺寸未知時會延後渲染。 所以不要在 compose 階段就倒入無界資料,或讓隱藏的 Widget 無止境累積 deferred writes。 本文只示範持續掛載的單一檢視器;若加上多頁與隱藏面板,須另外管理其渲染生命週期。

八. 純文字與遮蔽:顯示不是信任邊界
#

markup=False、highlight=False 加上明確的 Text(...),避免把輸入當成 Rich 標記或可執行樣式。 控制字元會先移除,換行與回車則轉成可見分隔,避免混淆事件邊界。 例如 [red]hello 會真的顯示方括號,而不是偷偷變成紅字。 請勿改成 Text.from_ansi() 或允許任意 Rich renderable,除非你重新檢查了信任模型。

clean() 的 regex 只示範遮蔽 token=非空白內容、password=非空白內容。 它不認得所有秘密:JSON 欄位、Bearer header、含空格密碼或自訂識別碼都可能漏網。 正式來源優先採結構化欄位白名單,在產生 log 時就排除秘密;UI regex 不能代替這件事。 遮蔽在放進 queue/history 前完成,才不會留下「畫面遮了、記憶體歷史仍有原文」的錯誤契約。 原始字串仍可能存在於呼叫端,本例沒有承諾記憶體抹除或完整的秘密偵測。

九. 測試:真的按鍵,確認凍結與過濾
#

把下面存成 test_viewer.py。 測試同時涵蓋溢位政策、拒絕長輸入、文字整理,以及真實 Widget 的暫停/恢復。 pilot.pause() 用來等候 UI 訊息處理,不拿任意一秒鐘 sleep 猜測是否已完成。 互動測試方法詳見 Textual Testing。

import pytest
from textual.widgets import Input, RichLog
from viewer import Inbox, Viewer, clean


def text(log):
    return "\n".join(line.text for line in log.lines)


def test_overflow_and_admission():
    inbox = Inbox(capacity=2)
    for row in ["old", "middle", "new"]:
        inbox.offer(row)
    inbox.offer("x" * 4097)
    rows, pending, dropped, rejected = inbox.drain()
    assert (rows, pending, dropped, rejected) == (["middle", "new"], 0, 1, 1)
    assert clean("token=abc\n[red]hello\x1b") == "token=[REDACTED] ↵ [red]hello"


@pytest.mark.asyncio
async def test_freeze_filter_and_resume():
    app = Viewer(demo=False)
    async with app.run_test(size=(90, 25)) as pilot:
        await pilot.pause()
        app.inbox.offer("INFO [red]safe token=abc")
        app.flush()
        await pilot.pause()
        log = app.query_one(RichLog)
        frozen = text(log)
        assert "[red]safe token=[REDACTED]" in frozen
        await pilot.press("ctrl+p")
        app.inbox.offer("ERROR newest")
        app.flush()
        assert text(log) == frozen
        assert app.history[-1] == "ERROR newest"
        app.query_one(Input).value = "error"
        await pilot.pause()
        assert text(log) == frozen
        await pilot.press("ctrl+p")
        app.flush()
        await pilot.pause()
        assert text(log) == "ERROR newest"
        await pilot.press("ctrl+f")
        assert app.following is False
uv run pytest -q

這兩個測試是可直接執行的最小回歸集合,不是效能 benchmark。 實際接上服務前,再用突發事件灌滿 Inbox,確認 dropped、rejected 和 retired 沒有混算。 也要測 80×24 小視窗、連續切換 filter、長時間暫停,以及多個 thread 同時 offer。 若要測 follow 的視覺位置,應產生超過 viewport 的資料,檢查 scroll offset,而非只看 boolean。 不能因為測試通過,就假設所有終端模擬器、ANSI 輸入或百萬筆流量都已驗證。

結語:好用的 Log Viewer,先把「看不到」說清楚
#

拍拍君最在意的不是畫面能印幾種顏色,而是每個動作都有明確契約。 輸入有限、歷史有限、畫面有限;丟棄與拒絕要計數,過濾要能從保留窗重建。 暫停讓人讀得完,follow 讓人跟得上,兩者不要混成一個開關。 最後記住:檢視器是觀測視窗,不是可靠儲存,也不是任務完成證明。

延伸閱讀
#

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

相關文章

Textual Modes 實戰:Workspace 切換、畫面生命週期與路由測試
·7 分鐘· loading · loading
Python Textual TUI Modes Screen-Routing Lifecycle Testing
Textual DirectoryTree 檔案瀏覽器:安全路徑、非同步預覽與互動測試
·8 分鐘· loading · loading
Python Textual DirectoryTree TUI Pathlib Asyncio Testing
Textual Command Palette + Actions:快捷鍵、命令搜尋與可測試操作
·8 分鐘· loading · loading
Python Textual TUI Command-Palette Actions Key-Bindings Testing
Textual CSS Layout 實戰:Grid、Dock、Responsive 與 Theme
·7 分鐘· loading · loading
Python Textual TCSS TUI Layout Responsive-Design Theme
Textual 測試實戰:Pilot、pytest-asyncio 與 Snapshot Regression
·7 分鐘· loading · loading
Python Textual TUI Pytest Pytest-Asyncio Pilot Snapshot Testing
Textual Form Wizard 實戰:多步驟表單、Validation 與狀態切換
·5 分鐘· loading · loading
Python Textual TUI Forms Validation Developer-Tools