案例:宣告式驗證
案例:宣告式驗證
本案例基於 .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 []這個設計的優點
- 直覺易懂:每個
check_*方法負責一項檢查 - 彈性高:容易新增或修改檢查邏輯
- 調試方便:可以單獨執行任一檢查方法
這個設計的限制
當需要設定類別時(例如 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:建立基礎 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練習
基礎練習
- 實作一個
EmailFieldDescriptor,驗證 email 格式 - 實作一個
LengthFieldDescriptor,驗證字串長度
進階練習
- 修改
ValidatedField,支援可選欄位(允許None) - 實作一個
CompositeField,可以組合多個驗證規則
挑戰題
- 參考
hook_validator.py的check_lib_imports方法,用 Descriptor 實現「根據欄位值決定是否需要驗證另一個欄位」的邏輯
延伸閱讀
#python #python-advanced #metaprogramming #metaclass #case-study