一. 前言:資料管理不一定要開瀏覽器 #
很多內部小工具其實不需要完整 Web App。 例如管理一份待辦資料、查看本機 job queue、整理檔案處理紀錄,或幫 CLI 加上一個可以互動瀏覽的介面。 你可以用 FastAPI 加前端,也可以用 Streamlit 快速拉出頁面。 但有些情境更適合留在終端機裡:
- 使用者本來就在 terminal 工作。
- 工具主要跑在本機或 server 上。
- 資料量不大,但需要搜尋、篩選、編輯。
- 你想要比純 CLI 更好操作,又不想維護 Web stack。
這時候 Textual + SQLite 是很舒服的組合。 Textual 負責互動介面、鍵盤操作、表格與狀態更新。 SQLite 負責把資料穩定存下來,而且不用另外開 server。
如果你還沒看過前面的文章,可以先補兩篇: Python Textual 實戰 介紹 TUI 基礎; sqlite3 完全攻略 介紹 Python 內建 SQLite 操作。 今天這篇不重講兩邊入門。 我們直接把它們組在一起,做一個「書籤資料管理小工具」。
目標功能很樸素: 列出書籤、新增、編輯、刪除、搜尋,然後把資料存在本機 SQLite 檔案。 樸素很好。 小工具最重要的是穩、好懂、容易改。
二. 專案結構:先把 UI 和資料層分開 #
先建立專案:
mkdir textual-sqlite-bookmarks
cd textual-sqlite-bookmarks
uv init
uv add textual
SQLite 用 Python 標準函式庫 sqlite3,不用另外安裝。
建議先用這個結構:
textual-sqlite-bookmarks/
├── app.py
├── bookmarks.db
└── src/
├── __init__.py
├── models.py
├── repository.py
└── screens.py
小型 demo 當然可以全部塞進 app.py。
但只要你打算繼續加功能,拍拍君建議一開始就切開:
models.py:資料物件。repository.py:SQLite 存取。screens.py:Textual 畫面。app.py:組裝 App、綁定快捷鍵。
這樣做有一個很實際的好處: 資料層可以單獨測試,UI 層也比較不會到處出現 SQL 字串。
三. 建立資料模型:先決定 App 裡的資料長什麼樣子 #
建立 src/models.py:
from __future__ import annotations
from dataclasses import dataclass
@dataclass(slots=True)
class Bookmark:
id: int | None
title: str
url: str
tags: str = ""
note: str = ""
@property
def display_tags(self) -> str:
return self.tags or "-"
這裡用 dataclass 就夠了。
我們不需要 ORM,也不需要複雜 validation。
這個 App 的核心資料只有五個欄位:
id、title、url、tags、note。
為什麼不直接用 dict?
因為 TUI 程式很容易到處傳資料。
用明確的 Bookmark 物件,可以讓欄位名稱集中,也讓 IDE 補全比較可靠。
四. Repository:把 SQLite 操作關在一個地方 #
建立 src/repository.py。
先處理連線與資料表初始化:
from __future__ import annotations
import sqlite3
from pathlib import Path
from .models import Bookmark
class BookmarkRepository:
def __init__(self, path: str | Path = "bookmarks.db") -> None:
self.path = Path(path)
self.init_db()
def connect(self) -> sqlite3.Connection:
conn = sqlite3.connect(self.path)
conn.row_factory = sqlite3.Row
return conn
def init_db(self) -> None:
with self.connect() as conn:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS bookmarks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
url TEXT NOT NULL,
tags TEXT NOT NULL DEFAULT '',
note TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)
"""
)
row_factory = sqlite3.Row 很值得加。
它讓查詢結果可以用欄位名稱存取,而不是背 tuple 順序。
接著補上轉換與查詢:
def _row_to_bookmark(self, row: sqlite3.Row) -> Bookmark:
return Bookmark(
id=row["id"],
title=row["title"],
url=row["url"],
tags=row["tags"],
note=row["note"],
)
def list_bookmarks(self, query: str = "") -> list[Bookmark]:
sql = "SELECT id, title, url, tags, note FROM bookmarks"
params: tuple[str, ...] = ()
if query:
sql += " WHERE title LIKE ? OR url LIKE ? OR tags LIKE ? OR note LIKE ?"
pattern = f"%{query}%"
params = (pattern, pattern, pattern, pattern)
sql += " ORDER BY id DESC"
with self.connect() as conn:
rows = conn.execute(sql, params).fetchall()
return [self._row_to_bookmark(row) for row in rows]
再寫新增、更新、刪除:
def add(self, bookmark: Bookmark) -> int:
with self.connect() as conn:
cursor = conn.execute(
"""
INSERT INTO bookmarks (title, url, tags, note)
VALUES (?, ?, ?, ?)
""",
(bookmark.title, bookmark.url, bookmark.tags, bookmark.note),
)
return int(cursor.lastrowid)
def update(self, bookmark: Bookmark) -> None:
if bookmark.id is None:
raise ValueError("bookmark.id is required for update")
with self.connect() as conn:
conn.execute(
"""
UPDATE bookmarks
SET title = ?, url = ?, tags = ?, note = ?
WHERE id = ?
""",
(bookmark.title, bookmark.url, bookmark.tags, bookmark.note, bookmark.id),
)
def delete(self, bookmark_id: int) -> None:
with self.connect() as conn:
conn.execute("DELETE FROM bookmarks WHERE id = ?", (bookmark_id,))
有兩個重點。
第一,所有外部輸入都用參數化查詢,不要自己組 SQL 字串。
第二,Repository 不知道 Textual 的存在。
它只收 Bookmark,回傳 Bookmark。
這讓資料層可以被 CLI、測試、排程腳本共用。
五. 先做最小 Textual App:畫面跑起來再加功能 #
建立 app.py:
from __future__ import annotations
from textual.app import App, ComposeResult
from textual.containers import Horizontal
from textual.widgets import Button, DataTable, Footer, Header, Input, Label
from src.repository import BookmarkRepository
class BookmarkApp(App):
CSS = """
Screen {
layout: vertical;
}
#toolbar {
height: 3;
padding: 0 1;
}
#search {
width: 1fr;
}
DataTable {
height: 1fr;
}
"""
BINDINGS = [
("q", "quit", "Quit"),
("n", "new_bookmark", "New"),
("e", "edit_bookmark", "Edit"),
("d", "delete_bookmark", "Delete"),
("/", "focus_search", "Search"),
]
def __init__(self) -> None:
super().__init__()
self.repo = BookmarkRepository()
def compose(self) -> ComposeResult:
yield Header()
with Horizontal(id="toolbar"):
yield Label("Search:")
yield Input(placeholder="title, url, tag...", id="search")
yield Button("New", id="new", variant="primary")
yield DataTable(id="bookmarks")
yield Footer()
這裡先放三個核心 UI: 搜尋輸入框、新增按鈕、資料表格。
再加上初始化表格:
def on_mount(self) -> None:
table = self.query_one("#bookmarks", DataTable)
table.add_columns("ID", "Title", "URL", "Tags")
table.cursor_type = "row"
self.refresh_table()
def refresh_table(self, query: str = "") -> None:
table = self.query_one("#bookmarks", DataTable)
table.clear()
for bookmark in self.repo.list_bookmarks(query):
table.add_row(
str(bookmark.id),
bookmark.title,
bookmark.url,
bookmark.display_tags,
key=str(bookmark.id),
)
啟動看看:
uv run textual run app.py
目前資料庫還是空的,所以表格沒有資料。 這很正常。 下一步來做新增畫面。
六. 新增畫面:用 Screen 收集輸入 #
Textual 裡可以用 Screen 做獨立畫面。
建立 src/screens.py:
from __future__ import annotations
from textual.app import ComposeResult
from textual.containers import Vertical
from textual.screen import ModalScreen
from textual.widgets import Button, Input, Label, TextArea
from .models import Bookmark
class BookmarkForm(ModalScreen[Bookmark | None]):
CSS = """
BookmarkForm {
align: center middle;
}
#dialog {
width: 70;
height: auto;
border: round $primary;
padding: 1 2;
background: $surface;
}
"""
def __init__(self, bookmark: Bookmark | None = None) -> None:
super().__init__()
self.bookmark = bookmark
def compose(self) -> ComposeResult:
current = self.bookmark
with Vertical(id="dialog"):
yield Label("Title")
yield Input(value=current.title if current else "", id="title")
yield Label("URL")
yield Input(value=current.url if current else "", id="url")
yield Label("Tags")
yield Input(value=current.tags if current else "", id="tags")
yield Label("Note")
yield TextArea(current.note if current else "", id="note")
yield Button("Save", id="save", variant="primary")
yield Button("Cancel", id="cancel")
ModalScreen 的好處是清楚。
主畫面負責列表,表單畫面負責輸入。
使用者存檔後回傳一個 Bookmark,取消就回傳 None。
補上按鈕事件:
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "cancel":
self.dismiss(None)
return
title = self.query_one("#title", Input).value.strip()
url = self.query_one("#url", Input).value.strip()
tags = self.query_one("#tags", Input).value.strip()
note = self.query_one("#note", TextArea).text.strip()
if not title or not url:
self.notify("Title and URL are required", severity="error")
return
self.dismiss(
Bookmark(
id=self.bookmark.id if self.bookmark else None,
title=title,
url=url,
tags=tags,
note=note,
)
)
這裡只做最基本的 validation。 正式工具可以再加 URL 格式檢查、tag 正規化、重複網址警告。
七. 串起新增與搜尋流程 #
回到 app.py,先 import 表單與 model:
from src.models import Bookmark
from src.screens import BookmarkForm
新增 action:
@property
def current_query(self) -> str:
return self.query_one("#search", Input).value.strip()
def action_new_bookmark(self) -> None:
self.push_screen(BookmarkForm(), self.save_new_bookmark)
def save_new_bookmark(self, bookmark: object) -> None:
if not isinstance(bookmark, Bookmark):
return
self.repo.add(bookmark)
self.refresh_table(self.current_query)
self.notify("Bookmark added")
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "new":
self.action_new_bookmark()
搜尋則接 Input.Changed:
def on_input_changed(self, event: Input.Changed) -> None:
if event.input.id == "search":
self.refresh_table(event.value.strip())
def action_focus_search(self) -> None:
self.query_one("#search", Input).focus()
這樣按 / 就會跳到搜尋框。
輸入 python、sqlite、docs,表格會立刻更新。
如果資料很多,這裡可以加 debounce。 但本機 SQLite 幾千筆資料通常沒什麼壓力。 先保持簡單。
八. 編輯與刪除:一定要保存資料庫 id #
DataTable 的 row key 可以存 bookmark id。
前面 add_row(…, key=str(bookmark.id)) 已經放進去了。
編輯和刪除都要靠這個 id,不要靠標題或 row number。
先在 Repository 加 get:
def get(self, bookmark_id: int) -> Bookmark | None:
with self.connect() as conn:
row = conn.execute(
"""
SELECT id, title, url, tags, note
FROM bookmarks
WHERE id = ?
""",
(bookmark_id,),
).fetchone()
return self._row_to_bookmark(row) if row else None
主畫面加一個 helper:
def selected_bookmark_id(self) -> int | None:
table = self.query_one("#bookmarks", DataTable)
if table.cursor_row is None:
return None
cell_key = table.coordinate_to_cell_key(table.cursor_coordinate)
row_key = cell_key.row_key
return int(str(row_key.value)) if row_key else None
不同 Textual 版本的 DataTable API 可能有細節差異。 如果你的版本取 row key 的方式不同,先用 Textual devtools 看一下。 核心思路不變: UI 列表裡一定要保存資料庫 id。
編輯可以共用同一個表單:
def action_edit_bookmark(self) -> None:
bookmark_id = self.selected_bookmark_id()
if bookmark_id is None:
self.notify("Select a bookmark first", severity="warning")
return
bookmark = self.repo.get(bookmark_id)
if bookmark is None:
self.refresh_table(self.current_query)
self.notify("Bookmark not found", severity="error")
return
self.push_screen(BookmarkForm(bookmark), self.save_existing_bookmark)
def save_existing_bookmark(self, bookmark: object) -> None:
if not isinstance(bookmark, Bookmark):
return
self.repo.update(bookmark)
self.refresh_table(self.current_query)
self.notify("Bookmark updated")
刪除也很短:
def action_delete_bookmark(self) -> None:
bookmark_id = self.selected_bookmark_id()
if bookmark_id is None:
self.notify("Select a bookmark first", severity="warning")
return
self.repo.delete(bookmark_id)
self.refresh_table(self.current_query)
self.notify("Bookmark deleted")
正式使用時,拍拍君會建議補一個確認畫面。
尤其是資料不可重建的工具,不要讓 d 一按就消失。
九. 補一點種子資料,方便開發 #
每次開發都手動新增很煩。 可以寫一個小 script:
from src.models import Bookmark
from src.repository import BookmarkRepository
repo = BookmarkRepository()
repo.add(
Bookmark(
id=None,
title="Textual documentation",
url="https://textual.textualize.io/",
tags="python,tui,docs",
note="Official Textual docs",
)
)
repo.add(
Bookmark(
id=None,
title="SQLite documentation",
url="https://www.sqlite.org/docs.html",
tags="sqlite,database,docs",
note="SQLite reference",
)
)
存成 seed.py,然後跑:
uv run python seed.py
uv run textual run app.py
表格裡有資料之後,UI 問題會更容易看出來。 例如欄寬、長 URL、tag 顯示、搜尋行為,都要靠真資料才看得準。
十. 工程化檢查:小工具也要有邊界 #
這種 App 很容易一路長大。
一開始只是書籤,後來加分類、狀態、同步、匯入匯出、批次檢查網址。
如果前面沒有邊界,最後會變成 app.py 裡有 800 行 UI + SQL + validation。
拍拍君會用幾個簡單原則控住:
- UI 只負責顯示與收集操作。
- Repository 只負責資料庫。
- Model 只描述資料形狀。
- 跨層傳遞明確物件,不傳半成品 dict。
- 所有 SQL 都集中在 Repository。
- 每次資料改變後,只重新整理必要的 view。
如果要加測試,先測 Repository。 例如:
from src.models import Bookmark
from src.repository import BookmarkRepository
def test_add_and_list_bookmark(tmp_path):
repo = BookmarkRepository(tmp_path / "test.db")
repo.add(Bookmark(None, "Daily Pypy", "https://dailypypy.org/", "python", ""))
bookmarks = repo.list_bookmarks("Pypy")
assert len(bookmarks) == 1
assert bookmarks[0].title == "Daily Pypy"
這個測試不用開 Textual,也不用真的操作 UI。 先把資料層穩住,TUI 才不會每改一個按鈕就開始懷疑人生。
十一. 可以繼續加什麼? #
做到這裡,工具已經有基本骨架。 接下來可以很自然地加功能:
- 匯入瀏覽器 bookmark HTML。
- 匯出 CSV 或 JSONL。
tag:python這種進階搜尋語法。- 收藏與封存狀態。
- 背景檢查網址是否還活著。
- 用 Rich markup 顯示 tag 顏色。
- 加一個 detail panel 顯示 note。
- 對資料庫做簡單 migration。
如果要做 migration,先不要急著上大型框架。
小工具可以用 PRAGMA user_version 管理 schema version。
等到資料模型真的變複雜,再考慮 Alembic 或 SQLAlchemy。
Textual 也可以繼續深入: 多個 Screen、CSS layout、worker 背景任務、command palette、key bindings。 TUI 的好處是,它可以很輕,也可以很完整。 你不必一開始就做成龐然大物。
結語 #
Textual + SQLite 很適合做「每天真的會用」的小型資料工具。 它沒有 Web server、沒有前端 build pipeline、沒有部署儀式。 一個 Python 專案、一個 SQLite 檔案、一個終端機介面,就能把資料管理流程做得比純 CLI 友善很多。
這篇的重點不是把 API 背完,而是把邊界抓清楚: Textual 管互動,SQLite 管資料,Repository 管 SQL,Model 管資料形狀。 只要這幾層不要混在一起,小工具長大時就還有救。
拍拍君最推薦的練習,是把你手邊某個 CSV 或 JSON 小清單改成 SQLite,再用 Textual 做一個瀏覽與編輯介面。 做完你會很快感覺到: 終端機不是只能輸入命令,也可以是一個很認真的工作台。