本案例基於 .claude/lib/hook_validator.py 的實際程式碼,展示如何用 Descriptor Protocol 實現宣告式驗證。

先備知識

問題背景

現有設計

hook_validator.py 使用命令式驗證方式:

 1class HookValidator:
 2    """Hook 合規性驗證器"""
 3
 4    # 驗證模式定義為類別常數
 5    VALID_NAME_PATTERNS = [
 6        r"^[a-z0-9](/python-advanced/02-metaprogramming/case-studies/declarative-validation/[a-z0-9\-_]*[a-z0-9])?\.py$",
 7    ]
 8
 9    HOOK_IO_PATTERNS = [
10        r"from\s+hook_io\s+import",
11        r"from\s+lib\.hook_io\s+import",
12    ]
13
14    def validate_hook(self, hook_path: str) -> ValidationResult:
15        """驗證單個 Hook 檔案"""
16        issues = []
17        issues.extend(self.check_naming_convention(hook_path))
18        issues.extend(self.check_lib_imports(content, hook_path))
19        issues.extend(self.check_output_format(content))
20        issues.extend(self.check_test_exists(hook_path))
21        return ValidationResult(hook_path=str(hook_path), issues=issues)
22
23    def check_naming_convention(self, hook_path: Path) -> List[ValidationIssue]:
24        """檢查命名規範"""
25        filename = hook_path.name
26        valid_name = any(
27            re.match(pattern, filename)
28            for pattern in self.VALID_NAME_PATTERNS
29        )
30        if not valid_name:
31            return [ValidationIssue(
32                level="warning",
33                message=f"檔案名稱不符合規範: {filename}",
34                suggestion="建議使用 snake-case 或 kebab-case 命名"
35            )]
36        return []

這個設計的優點

  1. 直覺易懂:每個 check_* 方法負責一項檢查
  2. 彈性高:容易新增或修改檢查邏輯
  3. 調試方便:可以單獨執行任一檢查方法

這個設計的限制

當需要設定類別時(例如 Hook 配置),命令式驗證會有問題:

 1class HookConfig:
 2    """Hook 配置類別"""
 3    def __init__(self, name: str, event: str, command: str):
 4        # 驗證邏輯散落在 __init__ 中
 5        if not re.match(r"^[a-z0-9][a-z0-9\-_]*[a-z0-9]$", name):
 6            raise ValueError(f"無效的 Hook 名稱: {name}")
 7        if event not in ("PreToolUse", "PostToolUse", "Stop"):
 8            raise ValueError(f"無效的事件類型: {event}")
 9        if not command:
10            raise ValueError("命令不能為空")
11
12        self.name = name
13        self.event = event
14        self.command = command

問題:

  • 驗證邏輯在 __init__ 中,不容易重用
  • 修改屬性時不會重新驗證
  • 無法在類別定義中看到驗證規則

進階解決方案:宣告式驗證

設計目標

  1. 驗證規則在類別定義中可見
  2. 賦值時自動驗證
  3. 驗證邏輯可重用

實作步驟

步驟 1:建立基礎 Descriptor

 1import re
 2from typing import Any, Callable, Optional
 3
 4class ValidatedField:
 5    """
 6    驗證欄位 Descriptor
 7
 8    將驗證邏輯封裝在屬性定義中,
 9    賦值時自動執行驗證。
10    """
11
12    def __init__(
13        self,
14        validator: Callable[[Any], bool],
15        error_msg: str = "驗證失敗"
16    ):
17        """
18        Args:
19            validator: 驗證函式,接受值,返回 bool
20            error_msg: 驗證失敗時的錯誤訊息
21        """
22        self.validator = validator
23        self.error_msg = error_msg
24        # __set_name__ 會設定這些
25        self.name: str = ""
26        self.private_name: str = ""
27
28    def __set_name__(self, owner: type, name: str) -> None:
29        """
30        Python 3.6+ 自動呼叫,取得屬性名稱
31
32        Args:
33            owner: 擁有此 Descriptor 的類別
34            name: 屬性名稱
35        """
36        self.name = name
37        self.private_name = f"_{name}"
38
39    def __get__(self, obj: Any, objtype: type = None) -> Any:
40        """
41        讀取屬性時呼叫
42
43        Args:
44            obj: 實例(如果透過實例存取)
45            objtype: 類別
46
47        Returns:
48            屬性值,或透過類別存取時返回 Descriptor 本身
49        """
50        if obj is None:
51            return self  # 透過類別存取,返回 Descriptor
52        return getattr(obj, self.private_name, None)
53
54    def __set__(self, obj: Any, value: Any) -> None:
55        """
56        設定屬性時呼叫,執行驗證
57
58        Args:
59            obj: 實例
60            value: 要設定的值
61
62        Raises:
63            ValueError: 驗證失敗時
64        """
65        if not self.validator(value):
66            raise ValueError(f"{self.name}: {self.error_msg}")
67        setattr(obj, self.private_name, value)

步驟 2:建立特化的驗證 Descriptor

 1class PatternField(ValidatedField):
 2    """
 3    正則表達式驗證欄位
 4
 5    簡化常見的模式匹配驗證。
 6    """
 7
 8    def __init__(self, pattern: str, error_msg: str = "格式不符"):
 9        """
10        Args:
11            pattern: 正則表達式模式
12            error_msg: 驗證失敗時的錯誤訊息
13        """
14        self.pattern = re.compile(pattern)
15        super().__init__(
16            validator=lambda v: bool(self.pattern.match(str(v))),
17            error_msg=error_msg
18        )
19
20class ChoiceField(ValidatedField):
21    """
22    選項驗證欄位
23
24    限制值必須在指定選項中。
25    """
26
27    def __init__(self, choices: tuple, error_msg: str = "無效的選項"):
28        """
29        Args:
30            choices: 允許的選項
31            error_msg: 驗證失敗時的錯誤訊息
32        """
33        self.choices = choices
34        super().__init__(
35            validator=lambda v: v in self.choices,
36            error_msg=f"{error_msg},必須是: {', '.join(str(c) for c in choices)}"
37        )
38
39class NonEmptyField(ValidatedField):
40    """
41    非空驗證欄位
42
43    確保值不為空。
44    """
45
46    def __init__(self, error_msg: str = "不能為空"):
47        super().__init__(
48            validator=lambda v: bool(v),
49            error_msg=error_msg
50        )
51
52class RangeField(ValidatedField):
53    """
54    範圍驗證欄位
55
56    確保數值在指定範圍內。
57    """
58
59    def __init__(
60        self,
61        min_val: Optional[float] = None,
62        max_val: Optional[float] = None,
63        error_msg: str = "超出範圍"
64    ):
65        """
66        Args:
67            min_val: 最小值(None 表示無下限)
68            max_val: 最大值(None 表示無上限)
69            error_msg: 驗證失敗時的錯誤訊息
70        """
71        self.min_val = min_val
72        self.max_val = max_val
73
74        def validator(v):
75            if min_val is not None and v < min_val:
76                return False
77            if max_val is not None and v > max_val:
78                return False
79            return True
80
81        range_desc = []
82        if min_val is not None:
83            range_desc.append(f">= {min_val}")
84        if max_val is not None:
85            range_desc.append(f"<= {max_val}")
86
87        super().__init__(
88            validator=validator,
89            error_msg=f"{error_msg},必須 {' 且 '.join(range_desc)}"
90        )

步驟 3:使用宣告式驗證

 1class HookConfig:
 2    """
 3    Hook 配置類別 - 宣告式驗證版本
 4
 5    驗證規則直接在類別定義中可見。
 6    """
 7
 8    # 宣告式驗證:欄位定義即驗證規則
 9    name = PatternField(
10        r"^[a-z0-9][a-z0-9\-_]*[a-z0-9]$",
11        "Hook 名稱必須是小寫字母、數字、連字號或底線,且不能以連字號開頭或結尾"
12    )
13
14    event = ChoiceField(
15        ("PreToolUse", "PostToolUse", "Stop", "SessionStart", "SessionEnd"),
16        "無效的事件類型"
17    )
18
19    command = NonEmptyField("命令不能為空")
20
21    timeout = RangeField(
22        min_val=0,
23        max_val=300,
24        error_msg="超時時間無效"
25    )
26
27    def __init__(
28        self,
29        name: str,
30        event: str,
31        command: str,
32        timeout: int = 30
33    ):
34        """
35        初始化 Hook 配置
36
37        Args:
38            name: Hook 名稱
39            event: 事件類型
40            command: 執行的命令
41            timeout: 超時時間(秒)
42
43        驗證會在賦值時自動執行。
44        """
45        self.name = name      # 自動驗證名稱格式
46        self.event = event    # 自動驗證事件類型
47        self.command = command  # 自動驗證非空
48        self.timeout = timeout  # 自動驗證範圍
49
50    def __repr__(self) -> str:
51        return (
52            f"HookConfig(name={self.name!r}, event={self.event!r}, "
53            f"command={self.command!r}, timeout={self.timeout})"
54        )

完整程式碼

  1#!/usr/bin/env python3
  2"""
  3宣告式驗證 - 完整範例
  4
  5展示如何用 Descriptor Protocol 實現宣告式驗證。
  6"""
  7
  8import re
  9from typing import Any, Callable, Optional
 10
 11# ===== Descriptor 定義 =====
 12
 13class ValidatedField:
 14    """驗證欄位 Descriptor 基類"""
 15
 16    def __init__(
 17        self,
 18        validator: Callable[[Any], bool],
 19        error_msg: str = "驗證失敗"
 20    ):
 21        self.validator = validator
 22        self.error_msg = error_msg
 23        self.name: str = ""
 24        self.private_name: str = ""
 25
 26    def __set_name__(self, owner: type, name: str) -> None:
 27        self.name = name
 28        self.private_name = f"_{name}"
 29
 30    def __get__(self, obj: Any, objtype: type = None) -> Any:
 31        if obj is None:
 32            return self
 33        return getattr(obj, self.private_name, None)
 34
 35    def __set__(self, obj: Any, value: Any) -> None:
 36        if not self.validator(value):
 37            raise ValueError(f"{self.name}: {self.error_msg}")
 38        setattr(obj, self.private_name, value)
 39
 40class PatternField(ValidatedField):
 41    """正則表達式驗證欄位"""
 42
 43    def __init__(self, pattern: str, error_msg: str = "格式不符"):
 44        self.pattern = re.compile(pattern)
 45        super().__init__(
 46            validator=lambda v: bool(self.pattern.match(str(v))),
 47            error_msg=error_msg
 48        )
 49
 50class ChoiceField(ValidatedField):
 51    """選項驗證欄位"""
 52
 53    def __init__(self, choices: tuple, error_msg: str = "無效的選項"):
 54        self.choices = choices
 55        super().__init__(
 56            validator=lambda v: v in self.choices,
 57            error_msg=f"{error_msg},必須是: {', '.join(str(c) for c in choices)}"
 58        )
 59
 60class NonEmptyField(ValidatedField):
 61    """非空驗證欄位"""
 62
 63    def __init__(self, error_msg: str = "不能為空"):
 64        super().__init__(
 65            validator=lambda v: bool(v),
 66            error_msg=error_msg
 67        )
 68
 69class RangeField(ValidatedField):
 70    """範圍驗證欄位"""
 71
 72    def __init__(
 73        self,
 74        min_val: Optional[float] = None,
 75        max_val: Optional[float] = None,
 76        error_msg: str = "超出範圍"
 77    ):
 78        self.min_val = min_val
 79        self.max_val = max_val
 80
 81        def validator(v):
 82            if min_val is not None and v < min_val:
 83                return False
 84            if max_val is not None and v > max_val:
 85                return False
 86            return True
 87
 88        range_desc = []
 89        if min_val is not None:
 90            range_desc.append(f">= {min_val}")
 91        if max_val is not None:
 92            range_desc.append(f"<= {max_val}")
 93
 94        super().__init__(
 95            validator=validator,
 96            error_msg=f"{error_msg},必須 {' 且 '.join(range_desc)}"
 97        )
 98
 99# ===== 使用範例 =====
100
101class HookConfig:
102    """Hook 配置類別 - 宣告式驗證"""
103
104    name = PatternField(
105        r"^[a-z0-9][a-z0-9\-_]*[a-z0-9]$",
106        "Hook 名稱格式無效"
107    )
108
109    event = ChoiceField(
110        ("PreToolUse", "PostToolUse", "Stop", "SessionStart", "SessionEnd"),
111        "無效的事件類型"
112    )
113
114    command = NonEmptyField("命令不能為空")
115
116    timeout = RangeField(min_val=0, max_val=300, error_msg="超時時間無效")
117
118    def __init__(self, name: str, event: str, command: str, timeout: int = 30):
119        self.name = name
120        self.event = event
121        self.command = command
122        self.timeout = timeout
123
124    def __repr__(self) -> str:
125        return f"HookConfig({self.name!r}, {self.event!r}, {self.command!r})"
126
127# ===== 測試 =====
128
129if __name__ == "__main__":
130    # 正確的配置
131    config = HookConfig(
132        name="check-format",
133        event="PreToolUse",
134        command="python check.py"
135    )
136    print(f"建立成功: {config}")
137
138    # 修改屬性也會驗證
139    config.timeout = 60
140    print(f"修改 timeout: {config.timeout}")
141
142    # 驗證失敗的例子
143    try:
144        bad_config = HookConfig(
145            name="Check-Format",  # 錯誤:大寫
146            event="PreToolUse",
147            command="python check.py"
148        )
149    except ValueError as e:
150        print(f"驗證失敗: {e}")
151
152    try:
153        config.event = "InvalidEvent"  # 錯誤:無效的事件
154    except ValueError as e:
155        print(f"驗證失敗: {e}")
156
157    try:
158        config.timeout = 500  # 錯誤:超出範圍
159    except ValueError as e:
160        print(f"驗證失敗: {e}")

使用範例

 1# 正確的配置
 2>>> config = HookConfig(
 3...     name="check-format",
 4...     event="PreToolUse",
 5...     command="python check.py"
 6... )
 7>>> config
 8HookConfig('check-format', 'PreToolUse', 'python check.py')
 9
10# 修改屬性也會驗證
11>>> config.timeout = 60
12>>> config.timeout
1360
14
15# 驗證失敗
16>>> config.name = "Invalid Name"
17ValueError: name: Hook 名稱格式無效
18
19>>> config.event = "BadEvent"
20ValueError: event: 無效的事件類型必須是: PreToolUse, PostToolUse, Stop, SessionStart, SessionEnd
21
22>>> config.timeout = -1
23ValueError: timeout: 超時時間無效必須 >= 0  <= 300

設計權衡

面向命令式驗證宣告式驗證(Descriptor)
可讀性驗證邏輯散落在方法中驗證規則在類別定義中可見
重用性需要複製驗證邏輯Descriptor 可在多個類別重用
賦值驗證需要手動呼叫驗證自動在賦值時驗證
複雜度簡單直覺需要理解 Descriptor Protocol
調試容易追蹤需要了解 __get__/__set__
彈性中等(需要遵循 Descriptor 協議)

什麼時候該用宣告式驗證?

適合使用

  • 資料類別(Data Class)需要驗證
  • 同樣的驗證規則需要在多處使用
  • 希望在類別定義中清楚看到驗證規則
  • 需要在屬性賦值時自動驗證

不建議使用

  • 簡單的一次性驗證
  • 驗證邏輯需要存取多個欄位
  • 團隊不熟悉 Descriptor Protocol
  • 驗證邏輯經常變動

進階:與 dataclass 結合

Python 3.7+ 的 dataclass 可以與 Descriptor 結合:

 1from dataclasses import dataclass, field
 2
 3@dataclass
 4class HookConfigDataclass:
 5    """使用 dataclass + Descriptor"""
 6
 7    # Descriptor 欄位需要特殊處理
 8    _name: str = field(init=False, repr=False)
 9    _event: str = field(init=False, repr=False)
10
11    # 公開欄位
12    name: str = field(default="")
13    event: str = field(default="PreToolUse")
14
15    # Descriptor 定義(類別變數)
16    name = PatternField(r"^[a-z0-9][a-z0-9\-_]*[a-z0-9]$", "無效的名稱")
17    event = ChoiceField(("PreToolUse", "PostToolUse", "Stop"), "無效的事件")
18
19    def __post_init__(self):
20        # 觸發 Descriptor 驗證
21        self.name = self.name
22        self.event = self.event

練習

基礎練習

  1. 實作一個 EmailField Descriptor,驗證 email 格式
  2. 實作一個 LengthField Descriptor,驗證字串長度

進階練習

  1. 修改 ValidatedField,支援可選欄位(允許 None
  2. 實作一個 CompositeField,可以組合多個驗證規則

挑戰題

  1. 參考 hook_validator.pycheck_lib_imports 方法,用 Descriptor 實現「根據欄位值決定是否需要驗證另一個欄位」的邏輯

延伸閱讀


上一章:案例研究索引 下一章:案例:自動註冊機制