一. 前言: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 負責清楚地呈現,我們負責決定哪些內容值得出現在畫面上。