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

Textual + SQLite 實戰:做一個終端機資料管理小工具

·8 分鐘· loading · loading · ·
Python Textual SQLite TUI Database Developer-Tools
每日拍拍
作者
每日拍拍
科學家 X 科技宅宅
目錄
Python 學習 - 本文屬於一個選集。
§ 67: 本文

featured

一. 前言:資料管理不一定要開瀏覽器
#

很多內部小工具其實不需要完整 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 的核心資料只有五個欄位: idtitleurltagsnote

為什麼不直接用 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()

這樣按 / 就會跳到搜尋框。 輸入 pythonsqlitedocs,表格會立刻更新。

如果資料很多,這裡可以加 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 做一個瀏覽與編輯介面。 做完你會很快感覺到: 終端機不是只能輸入命令,也可以是一個很認真的工作台。

延伸閱讀
#

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

相關文章

Python SQLAlchemy 2.0 實戰:Typed ORM、Session 與查詢模式
·9 分鐘· loading · loading
Python SQLAlchemy ORM Database SQLite Developer-Tools
Python Alembic 實戰:資料庫 Migration、版本控管與團隊協作
·9 分鐘· loading · loading
Python Alembic SQLAlchemy Database Migration Developer-Tools
Python Textual 實戰:終端機 TUI 應用開發完全攻略
·9 分鐘· loading · loading
Python Textual TUI Cli Terminal
Python uv scripts 實戰:PEP 723、inline dependencies 與單檔工具
·6 分鐘· loading · loading
Python Uv PEP 723 Script Developer-Tools Automation
Python pytest fixtures 進階:conftest、factory 與測試資料管理
·8 分鐘· loading · loading
Python Pytest Fixtures Testing Conftest Monkeypatch Developer-Tools
FastAPI + Streamlit 實戰:API 後端與互動前端分工
·9 分鐘· loading · loading
Python Fastapi Streamlit Api Frontend Developer-Tools