本案例基於 .claude/lib/hook_io.py 的實際程式碼,展示如何結合 Descriptor 和 dataclass 設計類似 Django Model Field 的宣告式 API。

先備知識

問題背景

現有設計

hook_io.py 使用函式工廠模式建構 Hook 輸出:

 1def create_pretooluse_output(
 2    decision: str,
 3    reason: str,
 4    user_prompt: Optional[str] = None,
 5    system_message: Optional[str] = None,
 6    suppress_output: bool = False
 7) -> dict:
 8    """
 9    建立 PreToolUse Hook 輸出格式
10
11    Args:
12        decision: 決策結果 ("allow" | "deny" | "ask")
13        reason: 決策原因說明
14        user_prompt: 詢問用戶的訊息(僅當 decision 為 "ask" 時使用)
15        system_message: 系統訊息(可選)
16        suppress_output: 是否抑制輸出(預設 False)
17
18    Returns:
19        dict: 標準 PreToolUse Hook 輸出格式
20    """
21    output: dict[str, Any] = {
22        "hookSpecificOutput": {
23            "hookEventName": "PreToolUse",
24            "permissionDecision": decision,
25            "permissionDecisionReason": reason
26        }
27    }
28
29    if user_prompt:
30        output["hookSpecificOutput"]["userPrompt"] = user_prompt
31
32    if system_message:
33        output["systemMessage"] = system_message
34
35    if suppress_output:
36        output["suppressOutput"] = True
37
38    return output

這個設計的優點

  • 清晰的建構流程:函式簽名清楚說明所需參數
  • 支援可選參數:使用 Optional 和預設值處理可選欄位
  • 型別提示完整:有完整的參數型別標註

這個設計的限制

當需要序列化/反序列化時:

  • 需要手動處理每個欄位:to_dict 和 from_dict 需要逐一處理
  • 欄位定義與驗證分離:型別檢查和業務驗證在不同地方
  • 難以生成文件或 schema:無法自動產生 JSON Schema 或 API 文件

進階解決方案

設計目標

  1. 欄位定義包含型別、驗證、序列化
  2. 自動生成 __init__to_dictfrom_dict
  3. 支援巢狀結構

實作步驟

步驟 1:設計 Field 基類

 1from typing import Any, Optional, Type, Generic, TypeVar, Callable, get_type_hints
 2
 3T = TypeVar("T")
 4
 5class Field(Generic[T]):
 6    """
 7    Field Descriptor base class
 8
 9    Combines Django's field declaration style with Python's
10    descriptor protocol for type-safe, declarative model design.
11    """
12
13    def __init__(
14        self,
15        *,
16        default: Optional[T] = None,
17        required: bool = True,
18        serialized_name: Optional[str] = None,
19        validator: Optional[Callable[[T], bool]] = None,
20        error_msg: str = "Validation failed"
21    ):
22        """
23        Args:
24            default: Default value when not provided
25            required: Whether field is required (default True)
26            serialized_name: Name used in serialization (default: attribute name)
27            validator: Optional validation function
28            error_msg: Error message for validation failure
29        """
30        self.default = default
31        self.required = required
32        self.serialized_name = serialized_name
33        self.validator = validator
34        self.error_msg = error_msg
35
36        # Set by __set_name__
37        self.name: str = ""
38        self.private_name: str = ""
39
40    def __set_name__(self, owner: type, name: str) -> None:
41        """
42        Called automatically when the descriptor is assigned to a class attribute.
43
44        This is where we get the attribute name from the class definition,
45        eliminating the need to pass it explicitly.
46        """
47        self.name = name
48        self.private_name = f"_field_{name}"
49        # Use attribute name as serialized name if not specified
50        if self.serialized_name is None:
51            self.serialized_name = name
52
53    def __get__(self, obj: Any, objtype: type = None) -> Any:
54        """Return field value or descriptor itself if accessed on class."""
55        if obj is None:
56            return self  # Class access returns descriptor
57        return getattr(obj, self.private_name, self.default)
58
59    def __set__(self, obj: Any, value: Any) -> None:
60        """Validate and set field value."""
61        # Handle None for optional fields
62        if value is None:
63            if self.required and self.default is None:
64                raise ValueError(f"{self.name}: This field is required")
65            setattr(obj, self.private_name, self.default)
66            return
67
68        # Run custom validator if provided
69        if self.validator and not self.validator(value):
70            raise ValueError(f"{self.name}: {self.error_msg}")
71
72        setattr(obj, self.private_name, value)
73
74    def serialize(self, value: T) -> Any:
75        """Convert Python value to serializable format."""
76        return value
77
78    def deserialize(self, value: Any) -> T:
79        """Convert serialized value to Python type."""
80        return value

關鍵設計要點

  1. __set_name__ 自動取得屬性名稱,不需要像 Django 早期版本手動傳入
  2. serialized_name 支援序列化時使用不同的欄位名稱(如 camelCase)
  3. serialize/deserialize 方法供子類覆寫,實現型別轉換

步驟 2:實作型別特定的 Field

  1import re
  2from datetime import datetime
  3from typing import List, Type, TypeVar
  4
  5class StringField(Field[str]):
  6    """String field with optional pattern validation."""
  7
  8    def __init__(
  9        self,
 10        *,
 11        pattern: Optional[str] = None,
 12        min_length: int = 0,
 13        max_length: Optional[int] = None,
 14        **kwargs
 15    ):
 16        super().__init__(**kwargs)
 17        self.pattern = re.compile(pattern) if pattern else None
 18        self.min_length = min_length
 19        self.max_length = max_length
 20
 21    def __set__(self, obj: Any, value: Any) -> None:
 22        if value is not None:
 23            if not isinstance(value, str):
 24                raise TypeError(f"{self.name}: Expected str, got {type(value).__name__}")
 25
 26            if self.min_length and len(value) < self.min_length:
 27                raise ValueError(f"{self.name}: Minimum length is {self.min_length}")
 28
 29            if self.max_length and len(value) > self.max_length:
 30                raise ValueError(f"{self.name}: Maximum length is {self.max_length}")
 31
 32            if self.pattern and not self.pattern.match(value):
 33                raise ValueError(f"{self.name}: Does not match pattern")
 34
 35        super().__set__(obj, value)
 36
 37class IntField(Field[int]):
 38    """Integer field with range validation."""
 39
 40    def __init__(
 41        self,
 42        *,
 43        min_value: Optional[int] = None,
 44        max_value: Optional[int] = None,
 45        **kwargs
 46    ):
 47        super().__init__(**kwargs)
 48        self.min_value = min_value
 49        self.max_value = max_value
 50
 51    def __set__(self, obj: Any, value: Any) -> None:
 52        if value is not None:
 53            if not isinstance(value, int) or isinstance(value, bool):
 54                raise TypeError(f"{self.name}: Expected int, got {type(value).__name__}")
 55
 56            if self.min_value is not None and value < self.min_value:
 57                raise ValueError(f"{self.name}: Minimum value is {self.min_value}")
 58
 59            if self.max_value is not None and value > self.max_value:
 60                raise ValueError(f"{self.name}: Maximum value is {self.max_value}")
 61
 62        super().__set__(obj, value)
 63
 64class BoolField(Field[bool]):
 65    """Boolean field."""
 66
 67    def __set__(self, obj: Any, value: Any) -> None:
 68        if value is not None and not isinstance(value, bool):
 69            raise TypeError(f"{self.name}: Expected bool, got {type(value).__name__}")
 70        super().__set__(obj, value)
 71
 72class ChoiceField(Field[str]):
 73    """Field with predefined choices."""
 74
 75    def __init__(self, *, choices: tuple[str, ...], **kwargs):
 76        super().__init__(**kwargs)
 77        self.choices = choices
 78
 79    def __set__(self, obj: Any, value: Any) -> None:
 80        if value is not None and value not in self.choices:
 81            choices_str = ", ".join(repr(c) for c in self.choices)
 82            raise ValueError(f"{self.name}: Must be one of: {choices_str}")
 83        super().__set__(obj, value)
 84
 85class ListField(Field[list]):
 86    """List field with item type validation."""
 87
 88    def __init__(self, *, item_field: Field, **kwargs):
 89        # ListField defaults to empty list, not required
 90        kwargs.setdefault("default", [])
 91        kwargs.setdefault("required", False)
 92        super().__init__(**kwargs)
 93        self.item_field = item_field
 94
 95    def __set__(self, obj: Any, value: Any) -> None:
 96        if value is not None:
 97            if not isinstance(value, list):
 98                raise TypeError(f"{self.name}: Expected list, got {type(value).__name__}")
 99            # Validate each item using item_field's validation logic
100            # (simplified - in production you'd want proper item validation)
101        super().__set__(obj, value if value is not None else [])
102
103    def serialize(self, value: list) -> list:
104        """Serialize list items."""
105        return [self.item_field.serialize(item) for item in value]
106
107    def deserialize(self, value: list) -> list:
108        """Deserialize list items."""
109        return [self.item_field.deserialize(item) for item in value]
110
111class DateTimeField(Field[datetime]):
112    """DateTime field with ISO format serialization."""
113
114    def __init__(self, *, format: str = "%Y-%m-%dT%H:%M:%S", **kwargs):
115        super().__init__(**kwargs)
116        self.format = format
117
118    def __set__(self, obj: Any, value: Any) -> None:
119        if value is not None and not isinstance(value, datetime):
120            raise TypeError(f"{self.name}: Expected datetime, got {type(value).__name__}")
121        super().__set__(obj, value)
122
123    def serialize(self, value: datetime) -> str:
124        """Convert datetime to ISO string."""
125        return value.strftime(self.format) if value else None
126
127    def deserialize(self, value: str) -> datetime:
128        """Parse ISO string to datetime."""
129        return datetime.strptime(value, self.format) if value else None

步驟 3:用 Metaclass 處理欄位收集

  1class ModelMeta(type):
  2    """
  3    Metaclass for Model classes.
  4
  5    Responsibilities:
  6    1. Collect all Field descriptors from class definition
  7    2. Store field metadata for serialization/deserialization
  8    3. Generate __init__ signature from fields
  9    """
 10
 11    def __new__(mcs, name: str, bases: tuple, namespace: dict):
 12        # Collect fields from this class and parent classes
 13        fields: dict[str, Field] = {}
 14
 15        # Inherit fields from parent Model classes
 16        for base in bases:
 17            if hasattr(base, "_fields"):
 18                fields.update(base._fields)
 19
 20        # Collect fields from current class
 21        for attr_name, attr_value in namespace.items():
 22            if isinstance(attr_value, Field):
 23                fields[attr_name] = attr_value
 24
 25        # Store fields metadata
 26        namespace["_fields"] = fields
 27
 28        return super().__new__(mcs, name, bases, namespace)
 29
 30class Model(metaclass=ModelMeta):
 31    """
 32    Base class for declarative models.
 33
 34    Provides:
 35    - Automatic __init__ from field definitions
 36    - to_dict() for serialization
 37    - from_dict() for deserialization
 38    """
 39
 40    _fields: dict[str, Field]  # Set by metaclass
 41
 42    def __init__(self, **kwargs):
 43        """
 44        Initialize model from keyword arguments.
 45
 46        All defined fields can be passed as keyword arguments.
 47        Required fields must be provided unless they have defaults.
 48        """
 49        # Set each field value, triggering descriptor validation
 50        for field_name, field in self._fields.items():
 51            value = kwargs.get(field_name, field.default)
 52            setattr(self, field_name, value)
 53
 54        # Check for unknown fields
 55        unknown = set(kwargs.keys()) - set(self._fields.keys())
 56        if unknown:
 57            raise TypeError(f"Unknown fields: {', '.join(unknown)}")
 58
 59    def to_dict(self) -> dict:
 60        """
 61        Serialize model to dictionary.
 62
 63        Uses each field's serialized_name and serialize() method.
 64        """
 65        result = {}
 66        for field_name, field in self._fields.items():
 67            value = getattr(self, field_name)
 68            if value is not None or field.required:
 69                serialized_name = field.serialized_name or field_name
 70                result[serialized_name] = field.serialize(value)
 71        return result
 72
 73    @classmethod
 74    def from_dict(cls, data: dict) -> "Model":
 75        """
 76        Deserialize dictionary to model instance.
 77
 78        Handles field name mapping and type conversion.
 79        """
 80        kwargs = {}
 81
 82        for field_name, field in cls._fields.items():
 83            serialized_name = field.serialized_name or field_name
 84
 85            if serialized_name in data:
 86                kwargs[field_name] = field.deserialize(data[serialized_name])
 87            elif field_name in data:
 88                # Fallback to field name if serialized name not found
 89                kwargs[field_name] = field.deserialize(data[field_name])
 90
 91        return cls(**kwargs)
 92
 93    def __repr__(self) -> str:
 94        """Generate readable representation."""
 95        field_strs = []
 96        for field_name in self._fields:
 97            value = getattr(self, field_name)
 98            field_strs.append(f"{field_name}={value!r}")
 99        return f"{self.__class__.__name__}({', '.join(field_strs)})"
100
101    def __eq__(self, other) -> bool:
102        """Compare models by their field values."""
103        if not isinstance(other, self.__class__):
104            return False
105        for field_name in self._fields:
106            if getattr(self, field_name) != getattr(other, field_name):
107                return False
108        return True

步驟 4:加入巢狀 Model 支援

 1class EmbeddedField(Field):
 2    """
 3    Embedded model field for nested structures.
 4
 5    Allows nesting Model instances within other Models.
 6    """
 7
 8    def __init__(self, *, model_class: Type[Model], **kwargs):
 9        super().__init__(**kwargs)
10        self.model_class = model_class
11
12    def __set__(self, obj: Any, value: Any) -> None:
13        if value is not None:
14            # Accept dict and convert to model
15            if isinstance(value, dict):
16                value = self.model_class.from_dict(value)
17            elif not isinstance(value, self.model_class):
18                raise TypeError(
19                    f"{self.name}: Expected {self.model_class.__name__} or dict, "
20                    f"got {type(value).__name__}"
21                )
22        super().__set__(obj, value)
23
24    def serialize(self, value: Model) -> dict:
25        """Serialize embedded model to dict."""
26        return value.to_dict() if value else None
27
28    def deserialize(self, value: dict) -> Model:
29        """Deserialize dict to embedded model."""
30        return self.model_class.from_dict(value) if value else None

完整程式碼

  1#!/usr/bin/env python3
  2"""
  3Django-style Field Descriptor - Complete Implementation
  4
  5Demonstrates how to combine Descriptor protocol with Metaclass
  6to create a declarative API similar to Django Model Fields.
  7"""
  8
  9from __future__ import annotations
 10import re
 11from datetime import datetime
 12from typing import Any, Optional, Type, Generic, TypeVar, Callable
 13
 14T = TypeVar("T")
 15
 16# ===== Field Descriptors =====
 17
 18class Field(Generic[T]):
 19    """Base Field Descriptor with validation and serialization."""
 20
 21    def __init__(
 22        self,
 23        *,
 24        default: Optional[T] = None,
 25        required: bool = True,
 26        serialized_name: Optional[str] = None,
 27        validator: Optional[Callable[[T], bool]] = None,
 28        error_msg: str = "Validation failed"
 29    ):
 30        self.default = default
 31        self.required = required
 32        self.serialized_name = serialized_name
 33        self.validator = validator
 34        self.error_msg = error_msg
 35        self.name: str = ""
 36        self.private_name: str = ""
 37
 38    def __set_name__(self, owner: type, name: str) -> None:
 39        self.name = name
 40        self.private_name = f"_field_{name}"
 41        if self.serialized_name is None:
 42            self.serialized_name = name
 43
 44    def __get__(self, obj: Any, objtype: type = None) -> Any:
 45        if obj is None:
 46            return self
 47        return getattr(obj, self.private_name, self.default)
 48
 49    def __set__(self, obj: Any, value: Any) -> None:
 50        if value is None:
 51            if self.required and self.default is None:
 52                raise ValueError(f"{self.name}: This field is required")
 53            setattr(obj, self.private_name, self.default)
 54            return
 55
 56        if self.validator and not self.validator(value):
 57            raise ValueError(f"{self.name}: {self.error_msg}")
 58
 59        setattr(obj, self.private_name, value)
 60
 61    def serialize(self, value: T) -> Any:
 62        return value
 63
 64    def deserialize(self, value: Any) -> T:
 65        return value
 66
 67class StringField(Field[str]):
 68    """String field with pattern and length validation."""
 69
 70    def __init__(
 71        self,
 72        *,
 73        pattern: Optional[str] = None,
 74        min_length: int = 0,
 75        max_length: Optional[int] = None,
 76        **kwargs
 77    ):
 78        super().__init__(**kwargs)
 79        self.pattern = re.compile(pattern) if pattern else None
 80        self.min_length = min_length
 81        self.max_length = max_length
 82
 83    def __set__(self, obj: Any, value: Any) -> None:
 84        if value is not None:
 85            if not isinstance(value, str):
 86                raise TypeError(f"{self.name}: Expected str, got {type(value).__name__}")
 87            if self.min_length and len(value) < self.min_length:
 88                raise ValueError(f"{self.name}: Minimum length is {self.min_length}")
 89            if self.max_length and len(value) > self.max_length:
 90                raise ValueError(f"{self.name}: Maximum length is {self.max_length}")
 91            if self.pattern and not self.pattern.match(value):
 92                raise ValueError(f"{self.name}: Does not match required pattern")
 93        super().__set__(obj, value)
 94
 95class IntField(Field[int]):
 96    """Integer field with range validation."""
 97
 98    def __init__(
 99        self,
100        *,
101        min_value: Optional[int] = None,
102        max_value: Optional[int] = None,
103        **kwargs
104    ):
105        super().__init__(**kwargs)
106        self.min_value = min_value
107        self.max_value = max_value
108
109    def __set__(self, obj: Any, value: Any) -> None:
110        if value is not None:
111            if not isinstance(value, int) or isinstance(value, bool):
112                raise TypeError(f"{self.name}: Expected int, got {type(value).__name__}")
113            if self.min_value is not None and value < self.min_value:
114                raise ValueError(f"{self.name}: Minimum value is {self.min_value}")
115            if self.max_value is not None and value > self.max_value:
116                raise ValueError(f"{self.name}: Maximum value is {self.max_value}")
117        super().__set__(obj, value)
118
119class BoolField(Field[bool]):
120    """Boolean field."""
121
122    def __set__(self, obj: Any, value: Any) -> None:
123        if value is not None and not isinstance(value, bool):
124            raise TypeError(f"{self.name}: Expected bool, got {type(value).__name__}")
125        super().__set__(obj, value)
126
127class ChoiceField(Field[str]):
128    """Field with predefined choices."""
129
130    def __init__(self, *, choices: tuple[str, ...], **kwargs):
131        super().__init__(**kwargs)
132        self.choices = choices
133
134    def __set__(self, obj: Any, value: Any) -> None:
135        if value is not None and value not in self.choices:
136            choices_str = ", ".join(repr(c) for c in self.choices)
137            raise ValueError(f"{self.name}: Must be one of: {choices_str}")
138        super().__set__(obj, value)
139
140class ListField(Field[list]):
141    """List field with item type."""
142
143    def __init__(self, *, item_field: Field, **kwargs):
144        kwargs.setdefault("default", [])
145        kwargs.setdefault("required", False)
146        super().__init__(**kwargs)
147        self.item_field = item_field
148
149    def __set__(self, obj: Any, value: Any) -> None:
150        if value is not None and not isinstance(value, list):
151            raise TypeError(f"{self.name}: Expected list, got {type(value).__name__}")
152        super().__set__(obj, value if value is not None else [])
153
154    def serialize(self, value: list) -> list:
155        return [self.item_field.serialize(item) for item in (value or [])]
156
157    def deserialize(self, value: list) -> list:
158        return [self.item_field.deserialize(item) for item in (value or [])]
159
160class DateTimeField(Field[datetime]):
161    """DateTime field with ISO format."""
162
163    def __init__(self, *, format: str = "%Y-%m-%dT%H:%M:%S", **kwargs):
164        super().__init__(**kwargs)
165        self.format = format
166
167    def __set__(self, obj: Any, value: Any) -> None:
168        if value is not None and not isinstance(value, datetime):
169            raise TypeError(f"{self.name}: Expected datetime, got {type(value).__name__}")
170        super().__set__(obj, value)
171
172    def serialize(self, value: datetime) -> Optional[str]:
173        return value.strftime(self.format) if value else None
174
175    def deserialize(self, value: str) -> Optional[datetime]:
176        return datetime.strptime(value, self.format) if value else None
177
178# ===== Metaclass and Model =====
179
180class ModelMeta(type):
181    """Metaclass that collects Field descriptors."""
182
183    def __new__(mcs, name: str, bases: tuple, namespace: dict):
184        fields: dict[str, Field] = {}
185
186        # Inherit from parent classes
187        for base in bases:
188            if hasattr(base, "_fields"):
189                fields.update(base._fields)
190
191        # Collect from current class
192        for attr_name, attr_value in namespace.items():
193            if isinstance(attr_value, Field):
194                fields[attr_name] = attr_value
195
196        namespace["_fields"] = fields
197        return super().__new__(mcs, name, bases, namespace)
198
199class Model(metaclass=ModelMeta):
200    """Base Model with automatic serialization."""
201
202    _fields: dict[str, Field]
203
204    def __init__(self, **kwargs):
205        for field_name, field in self._fields.items():
206            value = kwargs.get(field_name, field.default)
207            setattr(self, field_name, value)
208
209        unknown = set(kwargs.keys()) - set(self._fields.keys())
210        if unknown:
211            raise TypeError(f"Unknown fields: {', '.join(unknown)}")
212
213    def to_dict(self) -> dict:
214        result = {}
215        for field_name, field in self._fields.items():
216            value = getattr(self, field_name)
217            if value is not None or field.required:
218                key = field.serialized_name or field_name
219                result[key] = field.serialize(value)
220        return result
221
222    @classmethod
223    def from_dict(cls, data: dict) -> "Model":
224        kwargs = {}
225        for field_name, field in cls._fields.items():
226            key = field.serialized_name or field_name
227            if key in data:
228                kwargs[field_name] = field.deserialize(data[key])
229            elif field_name in data:
230                kwargs[field_name] = field.deserialize(data[field_name])
231        return cls(**kwargs)
232
233    def __repr__(self) -> str:
234        parts = [f"{k}={getattr(self, k)!r}" for k in self._fields]
235        return f"{self.__class__.__name__}({', '.join(parts)})"
236
237    def __eq__(self, other) -> bool:
238        if not isinstance(other, self.__class__):
239            return False
240        return all(
241            getattr(self, k) == getattr(other, k)
242            for k in self._fields
243        )
244
245class EmbeddedField(Field):
246    """Embedded model field for nested structures."""
247
248    def __init__(self, *, model_class: Type[Model], **kwargs):
249        super().__init__(**kwargs)
250        self.model_class = model_class
251
252    def __set__(self, obj: Any, value: Any) -> None:
253        if value is not None:
254            if isinstance(value, dict):
255                value = self.model_class.from_dict(value)
256            elif not isinstance(value, self.model_class):
257                raise TypeError(
258                    f"{self.name}: Expected {self.model_class.__name__} or dict"
259                )
260        super().__set__(obj, value)
261
262    def serialize(self, value: Model) -> Optional[dict]:
263        return value.to_dict() if value else None
264
265    def deserialize(self, value: dict) -> Optional[Model]:
266        return self.model_class.from_dict(value) if value else None

使用範例

 1# ===== Define Models =====
 2
 3class HookSpecificOutput(Model):
 4    """Nested model for hook-specific output."""
 5
 6    hook_event_name = ChoiceField(
 7        choices=("PreToolUse", "PostToolUse", "Stop"),
 8        serialized_name="hookEventName"
 9    )
10    permission_decision = ChoiceField(
11        choices=("allow", "deny", "ask"),
12        serialized_name="permissionDecision"
13    )
14    permission_reason = StringField(
15        min_length=1,
16        serialized_name="permissionDecisionReason"
17    )
18    user_prompt = StringField(
19        required=False,
20        serialized_name="userPrompt"
21    )
22
23class HookOutput(Model):
24    """
25    Hook Output model - similar to create_pretooluse_output
26
27    Declarative definition replaces the factory function.
28    """
29
30    hook_specific_output = EmbeddedField(
31        model_class=HookSpecificOutput,
32        serialized_name="hookSpecificOutput"
33    )
34    system_message = StringField(
35        required=False,
36        serialized_name="systemMessage"
37    )
38    suppress_output = BoolField(
39        default=False,
40        required=False,
41        serialized_name="suppressOutput"
42    )
43
44# ===== Usage Examples =====
45
46if __name__ == "__main__":
47    # Create model instance
48    output = HookOutput(
49        hook_specific_output=HookSpecificOutput(
50            hook_event_name="PreToolUse",
51            permission_decision="allow",
52            permission_reason="Operation permitted"
53        ),
54        system_message="Check completed"
55    )
56    print(f"Created: {output}")
57
58    # Serialize to dict (ready for JSON)
59    data = output.to_dict()
60    print(f"Serialized: {data}")
61    # Output:
62    # {
63    #     "hookSpecificOutput": {
64    #         "hookEventName": "PreToolUse",
65    #         "permissionDecision": "allow",
66    #         "permissionDecisionReason": "Operation permitted"
67    #     },
68    #     "systemMessage": "Check completed",
69    #     "suppressOutput": False
70    # }
71
72    # Deserialize from dict
73    restored = HookOutput.from_dict(data)
74    print(f"Restored: {restored}")
75    print(f"Equal: {output == restored}")
76
77    # Validation examples
78    try:
79        bad = HookSpecificOutput(
80            hook_event_name="InvalidEvent",  # Error: not in choices
81            permission_decision="allow",
82            permission_reason="Test"
83        )
84    except ValueError as e:
85        print(f"Validation error: {e}")
86
87    # Nested dict conversion
88    output2 = HookOutput(
89        hook_specific_output={  # Auto-converts dict to model
90            "hook_event_name": "PostToolUse",
91            "permission_decision": "deny",
92            "permission_reason": "Access denied"
93        }
94    )
95    print(f"From nested dict: {output2}")

執行結果

1Created: HookOutput(hook_specific_output=HookSpecificOutput(...), system_message='Check completed', suppress_output=False)
2Serialized: {'hookSpecificOutput': {'hookEventName': 'PreToolUse', 'permissionDecision': 'allow', 'permissionDecisionReason': 'Operation permitted'}, 'systemMessage': 'Check completed', 'suppressOutput': False}
3Restored: HookOutput(hook_specific_output=HookSpecificOutput(...), system_message='Check completed', suppress_output=False)
4Equal: True
5Validation error: hook_event_name: Must be one of: 'PreToolUse', 'PostToolUse', 'Stop'
6From nested dict: HookOutput(hook_specific_output=HookSpecificOutput(...), system_message=None, suppress_output=False)

設計權衡

面向函式工廠模式Field Descriptor
學習曲線低,只需了解函式中,需理解 Descriptor + Metaclass
程式碼量每個輸出類型一個函式一次性建立後可重用
欄位定義分散在函式簽名和函式體集中在類別定義中
型別提示需手動維護與欄位定義整合
序列化每個函式獨立實作自動生成
驗證時機呼叫時驗證賦值時驗證
巢狀結構需手動處理EmbeddedField 自動處理
Schema 生成無法自動化可從 _fields 元資料生成
調試直覺,錯誤位置明確需理解 Descriptor 協議

關鍵差異說明

函式工廠模式(如 hook_io.py):

  • 適合簡單、一次性的資料建構
  • 不需要額外的學習成本
  • 但重複程式碼多,難以維護

Field Descriptor 模式

  • 初期投入較高,但長期收益大
  • 欄位定義即文檔
  • 易於擴展和測試

什麼時候該用這個技術?

適合使用

  • ORM/ODM 設計(如 Django Model、MongoDB ODM)
  • 配置檔案解析(型別安全的 YAML/JSON 解析)
  • API 請求/回應模型(REST API、GraphQL)
  • 需要 Schema 自動生成的場景
  • 多個類似結構的資料類別

不建議使用

  • 簡單的資料容器(直接用 dataclass)
  • 不需要序列化的內部類別
  • Pydantic 已經滿足需求
  • 團隊不熟悉元編程
  • 單一用途的資料結構

與 Pydantic 的比較

功能自製 Field DescriptorPydantic
型別驗證手動實作自動
JSON Schema需額外實作內建
效能依實作而定高度優化
學習價值高,理解原理低,直接使用
客製化彈性完全控制受框架限制

建議:在生產環境優先考慮 Pydantic;學習元編程時使用自製實作。

練習

基礎練習

1. 實作 BoolFieldDateTimeField

完成以下 Field 類別,支援型別驗證和序列化:

1class BoolField(Field[bool]):
2    """Boolean field with strict type checking."""
3    pass  # TODO: implement
4
5class DateTimeField(Field[datetime]):
6    """DateTime field with custom format."""
7    pass  # TODO: implement

2. 新增欄位的預設值和 required 屬性

修改 Field 基類,讓以下程式碼能正確運作:

1class Config(Model):
2    name = StringField(required=True)
3    timeout = IntField(default=30, required=False)
4    debug = BoolField(default=False, required=False)
5
6# 應該能建立實例
7config = Config(name="test")
8assert config.timeout == 30
9assert config.debug == False

進階練習

3. 實作巢狀 Model(EmbeddedField

讓以下程式碼能正確運作:

 1class Address(Model):
 2    city = StringField()
 3    street = StringField()
 4
 5class Person(Model):
 6    name = StringField()
 7    address = EmbeddedField(model_class=Address)
 8
 9# 從巢狀 dict 建立
10person = Person.from_dict({
11    "name": "Alice",
12    "address": {"city": "Taipei", "street": "Main St"}
13})
14
15# 序列化回 dict
16data = person.to_dict()

4. 實作 ListField 的完整驗證

讓以下程式碼能驗證列表中的每個項目:

 1class Tag(Model):
 2    name = StringField(pattern=r"^[a-z]+$")
 3    priority = IntField(min_value=1, max_value=10)
 4
 5class Article(Model):
 6    title = StringField()
 7    tags = ListField(item_field=EmbeddedField(model_class=Tag))
 8
 9# 每個 tag 都應該被驗證
10article = Article(
11    title="Python Tips",
12    tags=[
13        {"name": "python", "priority": 5},
14        {"name": "INVALID", "priority": 5}  # Should raise error
15    ]
16)

挑戰題

5. 實作 Schema 自動生成

為 Model 類別新增 to_json_schema() 類別方法,自動生成 JSON Schema:

 1class User(Model):
 2    name = StringField(min_length=1, max_length=100)
 3    age = IntField(min_value=0, max_value=150)
 4    email = StringField(pattern=r"^[\w.-]+@[\w.-]+\.\w+$")
 5
 6schema = User.to_json_schema()
 7# 應該輸出:
 8# {
 9#     "type": "object",
10#     "properties": {
11#         "name": {"type": "string", "minLength": 1, "maxLength": 100},
12#         "age": {"type": "integer", "minimum": 0, "maximum": 150},
13#         "email": {"type": "string", "pattern": "^[\\w.-]+@[\\w.-]+\\.\\w+$"}
14#     },
15#     "required": ["name", "age", "email"]
16# }

延伸閱讀


上一章:自動註冊機制 返回:模組二:元編程