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

Rich Traceback 實戰:例外鏈、敏感資料遮蔽與 CLI 退出契約

·7 分鐘· loading · loading · ·
Python Rich Traceback Cli Stderr Exception-Chaining
每日拍拍
作者
每日拍拍
科學家 X 科技宅宅
目錄
Python 學習 - 本文屬於一個選集。
§ 146: 本文

一. 前言:Traceback 漂亮了,Token 也跟著上鏡?
#

拍拍君最近整理 CLI,遇到兩個看起來很小、實際很煩的問題。 第一個:錯誤訊息跑進 stdout,下一個程式讀 JSON 直接爆炸。 第二個:為了方便除錯打開區域變數,結果認證資料也被印進 CI log。

Rich 能把 traceback 排得很清楚,但可讀性不等於資料遮蔽。 我們今天要做的不是「所有錯誤換個顏色」,而是訂出輸出契約: 正常結果給機器、公開錯誤給使用者、原始診斷只在明確開啟的本機 debug 顯示。

這是 Rich + Typer CLI 的窄角度後續。 不重做表格、進度條與命令架構,也不接 Rich Logging Dashboard。 範例刻意用 argparse,讓焦點留在例外邊界,而不是框架的自動錯誤頁。

二. 先寫契約,再寫 except
#

這支 pypy_check.py 會讀 JSON 設定,要求 limit 是 1 到 100 的整數。 成功才輸出一份 JSON;業務處理失敗時,stdout 必須是空字串。

情況 stdout stderr 退出碼
設定有效 一份 JSON 空 0
檔案、編碼、JSON、欄位錯誤 空 固定公開訊息 2
非預期程式錯誤 空 固定內部錯誤訊息 1
使用者中斷 空 固定中斷訊息 130
加上 --debug 且失敗 仍然空 公開訊息加原始 traceback 同原錯誤

這些數字是本工具的約定,不是全世界 CLI 都得遵守的規格。 argparse 的 --help 是例外:它正常把說明寫到 stdout,退出 0。 參數解析失敗則由 argparse 自己在 stderr 顯示 usage,退出 2。 因此「固定公開訊息」的保證範圍是參數解析後的應用程式錯誤。

三. 安裝:固定版本,方便重現
#

python3 -m venv .venv
source .venv/bin/activate
python -m pip install rich==15.0.0 pytest

本文以 Python 3.12 為基準。 Rich 版本不同,框線、斷行或 traceback 的細節可能變動;別把整張畫面當協定。 我們測試的是資料流向與語意,不是每個 Unicode 角落是否一模一樣。

四. 完整 CLI:把原始例外留在內部
#

把下面整段存成 pypy_check.py。 UserError 的訊息只保存內部錯誤代碼,公開文字另外查固定對照表。 讀檔失敗仍保留原始 cause,但預設不把它轉成字串交給使用者。

import argparse
import json
from pathlib import Path

from rich.console import Console
from rich.text import Text

PUBLIC = {
    "input": "無法讀取設定;請確認檔案與 UTF-8 編碼。",
    "json": "設定不是有效 JSON;請修正格式。",
    "schema": "limit 必須是 1 到 100 的整數。",
}


class UserError(Exception):
    def __init__(self, code: str) -> None:
        super().__init__(code)
        self.code = code


def load_limit(path: Path) -> int:
    try:
        raw = path.read_text(encoding="utf-8")
    except (OSError, UnicodeError) as exc:
        raise UserError("input") from exc
    try:
        data = json.loads(raw)
    except (ValueError, RecursionError) as exc:
        raise UserError("json") from exc
    if not isinstance(data, dict):
        raise UserError("schema")
    limit = data.get("limit")
    if type(limit) is not int or not 1 <= limit <= 100:
        raise UserError("schema")
    return limit


def run(path: Path) -> dict:
    limit = load_limit(path)
    return {"owner": "拍拍君", "limit": limit}


def report_error(console: Console, message: str, debug: bool) -> None:
    console.print(Text(message, style="bold red"))
    if debug:
        # 必須在處理例外期間呼叫;這不是已遮蔽的輸出。
        console.print_exception(
            show_locals=False,
            extra_lines=1,
            max_frames=12,
            word_wrap=True,
        )


def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(description="拍拍君的設定檢查器")
    parser.add_argument("path", type=Path)
    parser.add_argument("--debug", action="store_true")
    args = parser.parse_args(argv)
    errors = Console(stderr=True)
    try:
        result = run(args.path)
    except UserError as exc:
        message = PUBLIC.get(exc.code, "設定無法處理;請檢查輸入。")
        report_error(errors, message, args.debug)
        return 2
    except KeyboardInterrupt:
        errors.print(Text("操作已中斷。"))
        return 130
    except Exception:
        report_error(errors, "內部錯誤;請聯絡維護者。", args.debug)
        return 1
    print(json.dumps(result, ensure_ascii=False))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

type(limit) is int 是有意的:Python 的 bool 是 int 的子類別, 如果用 isinstance(True, int),true 就可能被當成合法數字收下。 這不是設定格式大全,只是讓錯誤路徑有清楚、可執行的入口。 範例會一次讀完整個檔案,適合小型本機設定;不提供大檔或不可信輸入的配額保護。

還有兩個邊界:第一,最外層才用 except Exception 收尾;核心函式仍讓未知錯誤往上傳。 第二,不抓 BaseException,因此不會把 SystemExit 等流程控制硬改成「內部錯誤」。 輸出管線斷開、程序被強制終止或記憶體耗盡,也不在這份業務退出契約的保證內。

五. 例外鏈:保留原因,不必預設公開
#

在 load_limit() 裡,raise UserError("input") from exc 做了兩件事: 上層得到穩定的業務分類,原始讀檔例外則保存在 __cause__。 這讓測試和本機 debug 能追到源頭,又不要求一般使用者理解底層 I/O。

printf '{"limit": 3}\n' > ok.json
python pypy_check.py ok.json
python pypy_check.py missing.json
python pypy_check.py missing.json --debug

第一個命令輸出 {"owner": "拍拍君", "limit": 3}。 第二個只顯示公開讀檔提示,退出 2;第三個維持退出 2,但加上 cause 的診斷。 Rich 在這裡負責排版例外鏈,不替我們決定哪些資料可以公開。

不要為了畫面短,把所有轉換都寫成 raise ... from None。 它會抑制一般 traceback 的 context 顯示,不代表刪除原始例外或其資料。 __context__ 仍可能保留來源,其他收集器也可能另外讀取。 這種語法是顯示選擇,不是可靠的秘密清除程序。 詳見 Python 例外鏈機制。

六. 敏感資料遮蔽:不輸出,比猜哪些要塗黑可靠
#

本範例的公開路徑不是「先產生完整 traceback,再做 regex 替換」。 它是從固定訊息目錄重新建立可公開輸出,不包含原始例外文字與輸入路徑。 這是一種 allowlist 設計;代價是一般模式提供的診斷比較少。

如果需要上下文,也只挑選確定允許的欄位。 下面是獨立的公開診斷資料範例,不必插進前面的 CLI 才能執行:

import json


def public_context(raw: dict) -> dict:
    stage = raw.get("stage")
    count = raw.get("count")
    return {
        "stage": stage if stage in ("load", "validate") else "unknown",
        "count": count if type(count) is int and 0 <= count <= 100 else None,
        "credential": "[REDACTED]",
    }


context = {"stage": "load", "count": 3, "credential": "demo-secret"}
print(json.dumps(public_context(context)))

重點是沒有把 raw 整份 repr() 或 JSON dump 出來。 額外的 headers、url、payload 欄位也不會自動跟進輸出。 這段只處理受控、一般 dict 的預期型別;不是任意 Python 物件的通用序列化器。 若正式工具加入這份摘要,請送到 stderr 或受控紀錄,不要污染成功 JSON。

相反地,show_locals=False 只關閉區域變數的顯示。 原始例外字串可能有帶 Token 的 URL;原始碼可能有硬編碼值; 檔案路徑可能含帳號;add_note() 也可能帶入原始請求。 關閉 locals 無法替這些來源背書,debug 輸出仍應視為私密診斷。

同理,locals_max_string 是截短,不是遮蔽:秘密前幾個字仍可能留下。 locals_hide_sunder 依名稱隱藏,也不能保證敏感欄位剛好都有底線。 若你需要把診斷交給客服,應建立專用的安全報告,而不是把 --debug 預設打開。

七. stderr、JSON 與顏色:三件事分開想
#

Console(stderr=True) 決定診斷的通道。 Text(message) 讓文字不被當成 Rich markup;它不是內容過濾器。 NO_COLOR 控制顏色呈現,也不會刪除例外資料。 這三個開關解決的是不同問題。

python pypy_check.py ok.json > result.json 2> error.log
python pypy_check.py missing.json > failed.json 2> failed.log

成功時 result.json 可直接交給 JSON parser,error.log 為空。 第二個命令的 failed.json 應為空,錯誤說明留在 failed.log。 空檔不代表成功,自動化一定還要檢查退出碼;不要加上 || true 把它吃掉。

未指定 debug 時,不必為了 CI 再加一個「藏 traceback」環境變數。 反而應檢查部署命令沒有偷偷附上 --debug,也不要把私密 stderr 上傳公開 artifact。 第三方套件若自己 print() 或寫 log,仍可能破壞通道契約;測試要包含真實依賴路徑。

八. subprocess 測試:測程序,不只測回傳值
#

將下面存成同一目錄的 test_cli.py。 以真正的子程序執行,才能同時觀察 SystemExit、stdout 和 stderr。 PYTHONIOENCODING 固定示例的 UTF-8 文字,NO_COLOR 則減少顏色干擾。

import json
import os
from pathlib import Path
import subprocess
import sys

SCRIPT = Path(__file__).with_name("pypy_check.py")


def invoke(*args):
    return subprocess.run(
        [sys.executable, str(SCRIPT), *map(str, args)],
        capture_output=True, text=True, encoding="utf-8", timeout=10,
        env={**os.environ, "NO_COLOR": "1", "PYTHONIOENCODING": "utf-8"},
    )


def test_success(tmp_path):
    path = tmp_path / "ok.json"
    path.write_text('{"limit": 3}', encoding="utf-8")
    result = invoke(path)
    assert result.returncode == 0
    assert json.loads(result.stdout) == {"owner": "拍拍君", "limit": 3}
    assert result.stderr == ""


def test_private_path_is_not_public(tmp_path):
    path = tmp_path / "CANARY_PRIVATE_PATH.json"
    result = invoke(path)
    assert result.returncode == 2
    assert result.stdout == ""
    assert "無法讀取設定" in result.stderr
    assert "CANARY_PRIVATE_PATH" not in result.stderr
    assert "Traceback" not in result.stderr


def test_debug_keeps_failure_status(tmp_path):
    result = invoke(tmp_path / "missing.json", "--debug")
    assert result.returncode == 2
    assert result.stdout == ""
    assert "FileNotFoundError" in result.stderr
    assert "UserError" in result.stderr

執行 python -m pytest -q。 這三項先鎖住最重要的程序契約;正式專案再補錯誤 JSON、UTF-8、布林值與未知錯誤。 未知錯誤可由測試替換 run() 注入,別在正式 CLI 留一個「故意爆炸」選項。

更值得加的是外洩反例:讓假 SDK 把 canary 放進例外訊息與 note。 預設模式必須完全沒有 canary;本機 debug 則可能看到它,證明不能宣稱自動遮蔽。 測試資料請用人工假字串,不要拿真的 Token 當 canary。

九. Traceback 顯示選項,不是安全政策
#

需要看框架以外的路徑時,可評估 Rich 的 suppress。 它減少指定框架的程式碼展示,但不是把路徑與例外資訊全部抹除。 max_frames=12 只控制堆疊顯示量,也不是錯誤總長度、機密或資源使用量的完整限制。 這些選項的細節請對照 Rich Traceback 文件。

install() 適合讓入口的未處理例外改用 Rich 顯示; 本例已在入口自行處理,所以選擇明確呼叫 print_exception(),不額外安裝全域 hook。 同一個錯誤若先印一次又重新拋出,很容易被外層再印一次。 也不要在共用函式庫 import 時改整個應用程式的例外顯示政策。

若你用 Typer,請另外檢查該版本的 pretty exceptions 與 locals 設定。 我們沒有依賴那些預設值;換框架後仍要重跑子程序測試,不能只搬 Console 就算完成。

結語:漂亮是加分,邊界才是底盤
#

拍拍君會先問三件事:這段輸出給誰看、能不能被機器讀、失敗時程序是否真的失敗? 之後才輪到顏色、框線與堆疊長度。

保留 cause 有助於除錯;不公開 cause 有助於控制資料外流。 兩者不衝突,前提是把公開訊息與私密 traceback 分成不同路徑。 Rich 負責清楚地呈現,我們負責決定哪些內容值得出現在畫面上。

延伸閱讀
#

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

相關文章

Rich Logging Dashboard 實戰:進度、表格與 Log Console 整合
·7 分鐘· loading · loading
Python Rich Logging Cli Dashboard Developer-Tools
Python shlex 實戰:安全拆解 Shell 參數、Quote 與迷你指令語法
·6 分鐘· loading · loading
Python Shlex Shell Cli Security Standard-Library
Python difflib 實戰:文字差異比對、相似度比較與 patch 輸出完全攻略
·10 分鐘· loading · loading
Python Difflib Text-Processing Developer-Tools Cli
Python prompt_toolkit 實戰:打造互動式 CLI、Auto-Completion 與 REPL 完全攻略
·10 分鐘· loading · loading
Python Prompt_toolkit Cli REPL Developer-Tools
Python Textual 實戰:終端機 TUI 應用開發完全攻略
·9 分鐘· loading · loading
Python Textual TUI Cli Terminal
本地 LLM 實戰:Ollama + Python 打造自己的小助手
·9 分鐘· loading · loading
LLM Ollama Python AI Cli